[{"data":1,"prerenderedAt":3905},["ShallowReactive",2],{"content:\u002F10-databases\u002F02-normalization-design":3},{"title":4,"description":5,"path":6,"body":7},"Проектирование схем и нормализация","Схема базы данных - это не техническая формальность после написания Go-кода. Это модель предметной области, контракт между сервисами и набор инвариантов, которые переживут несколько версий приложения.","\u002F10-databases\u002F02-normalization-design",{"type":8,"value":9,"toc":3851},"minimark",[10,14,17,20,39,47,50,55,60,63,73,76,157,160,180,183,185,189,192,195,201,204,668,671,711,714,716,720,723,790,793,889,899,902,1016,1019,1021,1025,1028,1189,1192,1206,1209,1211,1215,1227,1299,1302,1319,1397,1408,1410,1414,1417,1445,1448,1508,1525,1528,1594,1597,1633,1636,1664,1675,1678,1701,1707,1709,1713,1717,1720,1723,1808,1811,1828,1831,1837,1844,1846,1850,1856,1859,1865,1889,1892,1961,1963,1967,1971,1974,1977,1999,2002,2053,2056,2060,2063,2065,2093,2109,2113,2116,2118,2144,2158,2160,2166,2170,2177,2180,2215,2225,2229,2232,2234,2286,2289,2295,2299,2302,2304,2308,2312,2315,2319,2322,2326,2329,2332,2334,2338,2342,2345,2348,2401,2404,2410,2413,2419,2421,2425,2428,2431,2437,2440,2446,2449,2466,2469,2471,2475,2479,2483,2489,2581,2587,2591,2649,2655,2659,2672,2675,2686,2689,2706,2709,2749,2753,2756,2855,2865,2869,2872,2955,2958,2960,2964,2967,2970,2990,2994,2997,3020,3022,3121,3127,3131,3138,3205,3208,3212,3215,3218,3292,3295,3472,3479,3482,3511,3513,3517,3551,3553,3557,3561,3564,3568,3571,3575,3581,3585,3591,3598,3607,3609,3613,3632,3634,3638,3655,3657,3661,3690,3816,3818,3822,3847],[11,12,4],"h1",{"id":13},"проектирование-схем-и-нормализация",[15,16,5],"p",{},[15,18,19],{},"Хорошая схема:",[21,22,23,27,30,33,36],"ul",{},[24,25,26],"li",{},"выражает бизнес-сущности и связи между ними;",[24,28,29],{},"запрещает невозможные состояния;",[24,31,32],{},"поддерживает основные запросы без мучений;",[24,34,35],{},"допускает эволюцию через миграции;",[24,37,38],{},"не заставляет backend держать всю целостность \"на честном слове\".",[15,40,41,42,46],{},"Плохая схема обычно проявляется позже: дубли, несогласованные статусы, тяжелые отчеты, невозможность удалить данные, миграции на часы, ",[43,44,45],"code",{},"NULL"," в половине колонок и бизнес-логика, размазанная по SQL-скриптам.",[48,49],"hr",{},[51,52,54],"h2",{"id":53},"er-модель-и-ограничения","ER-модель и ограничения",[56,57,59],"h3",{"id":58},"от-предметной-области-к-er-модели","От предметной области к ER-модели",[15,61,62],{},"ER-модель описывает сущности, атрибуты и связи.",[64,65,71],"pre",{"className":66,"code":68,"language":69,"meta":70},[67],"language-text","User 1 ─── * Order 1 ─── * OrderItem * ─── 1 Product\n                |\n                * Payment\n","text","",[43,72,68],{"__ignoreMap":70},[15,74,75],{},"Пример для учебной платформы:",[77,78,79,93],"table",{},[80,81,82],"thead",{},[83,84,85,90],"tr",{},[86,87,89],"th",{"align":88},"left","Сущность",[86,91,92],{"align":88},"Что хранит",[94,95,96,107,117,127,137,147],"tbody",{},[83,97,98,104],{},[99,100,101],"td",{"align":88},[43,102,103],{},"users",[99,105,106],{"align":88},"Аккаунты студентов, менторов и админов",[83,108,109,114],{},[99,110,111],{"align":88},[43,112,113],{},"courses",[99,115,116],{"align":88},"Курсы",[83,118,119,124],{},[99,120,121],{"align":88},[43,122,123],{},"lessons",[99,125,126],{"align":88},"Уроки внутри курса",[83,128,129,134],{},[99,130,131],{"align":88},[43,132,133],{},"enrollments",[99,135,136],{"align":88},"Запись пользователя на курс",[83,138,139,144],{},[99,140,141],{"align":88},[43,142,143],{},"submissions",[99,145,146],{"align":88},"Отправленные решения задач",[83,148,149,154],{},[99,150,151],{"align":88},[43,152,153],{},"reviews",[99,155,156],{"align":88},"Проверки и комментарии ментора",[15,158,159],{},"Сначала полезно писать модель словами:",[21,161,162,165,168,171,174,177],{},[24,163,164],{},"пользователь может быть записан на много курсов;",[24,166,167],{},"курс содержит много уроков;",[24,169,170],{},"урок принадлежит одному курсу;",[24,172,173],{},"пользователь может отправить много решений;",[24,175,176],{},"решение относится к конкретной задаче и пользователю;",[24,178,179],{},"проверка относится к решению и автору-ментору.",[15,181,182],{},"Потом это превращается в таблицы, ключи и constraints.",[48,184],{},[56,186,188],{"id":187},"иллюстративная-схема-для-ratedesk","Иллюстративная схема для RateDesk",[15,190,191],{},"Для RateDesk полезно сразу разделять разные типы фактов. \"Курс\" в разговоре может означать сырое значение от провайдера, выбранную сервисом ставку, правило расчета, заявку пользователя или audit-запись. Если смешать это в одной таблице, production-проблемы появятся быстро: невозможно объяснить расчет, трудно переиграть импорт, отчеты расходятся с текущим состоянием.",[15,193,194],{},"Упрощенная write model может выглядеть так:",[64,196,199],{"className":197,"code":198,"language":69,"meta":70},[67],"rate_providers(id, code, title, enabled)\ncurrency_pairs(id, base_currency, quote_currency)\nprovider_rate_quotes(id, provider_id, pair_id, bid, ask, received_at, provider_trace_id)\nrate_rules(id, pair_id, priority, spread_bps, valid_from, valid_to, enabled)\nrate_decisions(id, pair_id, quote_id, rule_id, final_rate, decided_at)\nrate_decision_audit(id, decision_id, actor, reason, created_at)\n",[43,200,198],{"__ignoreMap":70},[15,202,203],{},"Минимальный DDL-фрагмент:",[64,205,209],{"className":206,"code":207,"language":208,"meta":70,"style":70},"language-sql shiki shiki-themes github-dark","CREATE TABLE currency_pairs (\n    id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    base_currency  char(3) NOT NULL,\n    quote_currency char(3) NOT NULL,\n\n    CHECK (base_currency \u003C> quote_currency),\n    UNIQUE (base_currency, quote_currency)\n);\n\nCREATE TABLE provider_rate_quotes (\n    id                bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    provider_id       bigint NOT NULL REFERENCES rate_providers(id),\n    pair_id           bigint NOT NULL REFERENCES currency_pairs(id),\n    bid               numeric(20, 8) NOT NULL CHECK (bid > 0),\n    ask               numeric(20, 8) NOT NULL CHECK (ask > 0),\n    received_at       timestamptz NOT NULL,\n    provider_trace_id text NOT NULL,\n\n    CHECK (ask >= bid),\n    UNIQUE (provider_id, provider_trace_id)\n);\n\nCREATE TABLE rate_decisions (\n    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    pair_id     bigint NOT NULL REFERENCES currency_pairs(id),\n    quote_id    bigint NOT NULL REFERENCES provider_rate_quotes(id),\n    rule_id     bigint NOT NULL REFERENCES rate_rules(id),\n    final_rate  numeric(20, 8) NOT NULL CHECK (final_rate > 0),\n    decided_at  timestamptz NOT NULL DEFAULT now()\n);\n","sql",[43,210,211,231,258,282,300,307,322,331,337,342,354,374,391,406,445,476,489,501,506,519,527,532,537,549,569,583,598,613,644,663],{"__ignoreMap":70},[212,213,216,220,223,227],"span",{"class":214,"line":215},"line",1,[212,217,219],{"class":218},"snl16","CREATE",[212,221,222],{"class":218}," TABLE",[212,224,226],{"class":225},"svObZ"," currency_pairs",[212,228,230],{"class":229},"s95oV"," (\n",[212,232,234,237,240,243,246,249,252,255],{"class":214,"line":233},2,[212,235,236],{"class":229},"    id             ",[212,238,239],{"class":218},"bigint",[212,241,242],{"class":218}," GENERATED",[212,244,245],{"class":218}," ALWAYS",[212,247,248],{"class":218}," AS",[212,250,251],{"class":218}," IDENTITY",[212,253,254],{"class":218}," PRIMARY KEY",[212,256,257],{"class":229},",\n",[212,259,261,264,267,270,274,277,280],{"class":214,"line":260},3,[212,262,263],{"class":229},"    base_currency  ",[212,265,266],{"class":218},"char",[212,268,269],{"class":229},"(",[212,271,273],{"class":272},"sDLfK","3",[212,275,276],{"class":229},") ",[212,278,279],{"class":218},"NOT NULL",[212,281,257],{"class":229},[212,283,285,288,290,292,294,296,298],{"class":214,"line":284},4,[212,286,287],{"class":229},"    quote_currency ",[212,289,266],{"class":218},[212,291,269],{"class":229},[212,293,273],{"class":272},[212,295,276],{"class":229},[212,297,279],{"class":218},[212,299,257],{"class":229},[212,301,303],{"class":214,"line":302},5,[212,304,306],{"emptyLinePlaceholder":305},true,"\n",[212,308,310,313,316,319],{"class":214,"line":309},6,[212,311,312],{"class":218},"    CHECK",[212,314,315],{"class":229}," (base_currency ",[212,317,318],{"class":218},"\u003C>",[212,320,321],{"class":229}," quote_currency),\n",[212,323,325,328],{"class":214,"line":324},7,[212,326,327],{"class":218},"    UNIQUE",[212,329,330],{"class":229}," (base_currency, quote_currency)\n",[212,332,334],{"class":214,"line":333},8,[212,335,336],{"class":229},");\n",[212,338,340],{"class":214,"line":339},9,[212,341,306],{"emptyLinePlaceholder":305},[212,343,345,347,349,352],{"class":214,"line":344},10,[212,346,219],{"class":218},[212,348,222],{"class":218},[212,350,351],{"class":225}," provider_rate_quotes",[212,353,230],{"class":229},[212,355,357,360,362,364,366,368,370,372],{"class":214,"line":356},11,[212,358,359],{"class":229},"    id                ",[212,361,239],{"class":218},[212,363,242],{"class":218},[212,365,245],{"class":218},[212,367,248],{"class":218},[212,369,251],{"class":218},[212,371,254],{"class":218},[212,373,257],{"class":229},[212,375,377,380,382,385,388],{"class":214,"line":376},12,[212,378,379],{"class":229},"    provider_id       ",[212,381,239],{"class":218},[212,383,384],{"class":218}," NOT NULL",[212,386,387],{"class":218}," REFERENCES",[212,389,390],{"class":229}," rate_providers(id),\n",[212,392,394,397,399,401,403],{"class":214,"line":393},13,[212,395,396],{"class":229},"    pair_id           ",[212,398,239],{"class":218},[212,400,384],{"class":218},[212,402,387],{"class":218},[212,404,405],{"class":229}," currency_pairs(id),\n",[212,407,409,412,415,417,420,423,426,428,430,433,436,439,442],{"class":214,"line":408},14,[212,410,411],{"class":229},"    bid               ",[212,413,414],{"class":218},"numeric",[212,416,269],{"class":229},[212,418,419],{"class":272},"20",[212,421,422],{"class":229},", ",[212,424,425],{"class":272},"8",[212,427,276],{"class":229},[212,429,279],{"class":218},[212,431,432],{"class":218}," CHECK",[212,434,435],{"class":229}," (bid ",[212,437,438],{"class":218},">",[212,440,441],{"class":272}," 0",[212,443,444],{"class":229},"),\n",[212,446,448,451,453,455,457,459,461,463,465,467,470,472,474],{"class":214,"line":447},15,[212,449,450],{"class":229},"    ask               ",[212,452,414],{"class":218},[212,454,269],{"class":229},[212,456,419],{"class":272},[212,458,422],{"class":229},[212,460,425],{"class":272},[212,462,276],{"class":229},[212,464,279],{"class":218},[212,466,432],{"class":218},[212,468,469],{"class":229}," (ask ",[212,471,438],{"class":218},[212,473,441],{"class":272},[212,475,444],{"class":229},[212,477,479,482,485,487],{"class":214,"line":478},16,[212,480,481],{"class":229},"    received_at       ",[212,483,484],{"class":218},"timestamptz",[212,486,384],{"class":218},[212,488,257],{"class":229},[212,490,492,495,497,499],{"class":214,"line":491},17,[212,493,494],{"class":229},"    provider_trace_id ",[212,496,69],{"class":218},[212,498,384],{"class":218},[212,500,257],{"class":229},[212,502,504],{"class":214,"line":503},18,[212,505,306],{"emptyLinePlaceholder":305},[212,507,509,511,513,516],{"class":214,"line":508},19,[212,510,312],{"class":218},[212,512,469],{"class":229},[212,514,515],{"class":218},">=",[212,517,518],{"class":229}," bid),\n",[212,520,522,524],{"class":214,"line":521},20,[212,523,327],{"class":218},[212,525,526],{"class":229}," (provider_id, provider_trace_id)\n",[212,528,530],{"class":214,"line":529},21,[212,531,336],{"class":229},[212,533,535],{"class":214,"line":534},22,[212,536,306],{"emptyLinePlaceholder":305},[212,538,540,542,544,547],{"class":214,"line":539},23,[212,541,219],{"class":218},[212,543,222],{"class":218},[212,545,546],{"class":225}," rate_decisions",[212,548,230],{"class":229},[212,550,552,555,557,559,561,563,565,567],{"class":214,"line":551},24,[212,553,554],{"class":229},"    id          ",[212,556,239],{"class":218},[212,558,242],{"class":218},[212,560,245],{"class":218},[212,562,248],{"class":218},[212,564,251],{"class":218},[212,566,254],{"class":218},[212,568,257],{"class":229},[212,570,572,575,577,579,581],{"class":214,"line":571},25,[212,573,574],{"class":229},"    pair_id     ",[212,576,239],{"class":218},[212,578,384],{"class":218},[212,580,387],{"class":218},[212,582,405],{"class":229},[212,584,586,589,591,593,595],{"class":214,"line":585},26,[212,587,588],{"class":229},"    quote_id    ",[212,590,239],{"class":218},[212,592,384],{"class":218},[212,594,387],{"class":218},[212,596,597],{"class":229}," provider_rate_quotes(id),\n",[212,599,601,604,606,608,610],{"class":214,"line":600},27,[212,602,603],{"class":229},"    rule_id     ",[212,605,239],{"class":218},[212,607,384],{"class":218},[212,609,387],{"class":218},[212,611,612],{"class":229}," rate_rules(id),\n",[212,614,616,619,621,623,625,627,629,631,633,635,638,640,642],{"class":214,"line":615},28,[212,617,618],{"class":229},"    final_rate  ",[212,620,414],{"class":218},[212,622,269],{"class":229},[212,624,419],{"class":272},[212,626,422],{"class":229},[212,628,425],{"class":272},[212,630,276],{"class":229},[212,632,279],{"class":218},[212,634,432],{"class":218},[212,636,637],{"class":229}," (final_rate ",[212,639,438],{"class":218},[212,641,441],{"class":272},[212,643,444],{"class":229},[212,645,647,650,652,654,657,660],{"class":214,"line":646},29,[212,648,649],{"class":229},"    decided_at  ",[212,651,484],{"class":218},[212,653,384],{"class":218},[212,655,656],{"class":218}," DEFAULT",[212,658,659],{"class":218}," now",[212,661,662],{"class":229},"()\n",[212,664,666],{"class":214,"line":665},30,[212,667,336],{"class":229},[15,669,670],{},"Что здесь важно именно как теория проектирования:",[21,672,673,679,685,691,697,704],{},[24,674,675,678],{},[43,676,677],{},"provider_rate_quotes"," хранит входящий факт от провайдера и не должна перетираться \"текущим курсом\";",[24,680,681,684],{},[43,682,683],{},"rate_decisions"," хранит результат выбора и ссылку на quote\u002Frule, чтобы расчет можно было объяснить после инцидента;",[24,686,687,690],{},[43,688,689],{},"UNIQUE (provider_id, provider_trace_id)"," делает импорт идемпотентным при retry;",[24,692,693,696],{},[43,694,695],{},"CHECK (ask >= bid)"," ловит невозможную рыночную форму до попадания в доменную логику;",[24,698,699,700,703],{},"денежные и курсовые значения не хранятся в ",[43,701,702],{},"float",", потому что округление становится частью финансового бага;",[24,705,706,707,710],{},"если правило меняется во времени, ",[43,708,709],{},"valid_from\u002Fvalid_to"," и audit важнее, чем обновление одной \"активной\" строки без истории.",[15,712,713],{},"Это не единственно правильная схема. На практике могут появиться tenancy, версии правил, manual override, outbox, read model для dashboard и retention policy для старых quote'ов. Но базовая эвристика остается: отдельный факт - отдельная таблица или явно названный snapshot.",[48,715],{},[56,717,719],{"id":718},"cardinality","Cardinality",[15,721,722],{},"Cardinality описывает, сколько объектов одной сущности может быть связано с объектом другой сущности.",[77,724,725,738],{},[80,726,727],{},[83,728,729,732,735],{},[86,730,731],{"align":88},"Связь",[86,733,734],{"align":88},"Пример",[86,736,737],{"align":88},"Реализация",[94,739,740,761,776],{},[83,741,742,745,754],{},[99,743,744],{"align":88},"One-to-one",[99,746,747,750,751],{"align":88},[43,748,749],{},"user"," - ",[43,752,753],{},"user_profile",[99,755,756,757,760],{"align":88},"FK с ",[43,758,759],{},"UNIQUE"," или PK на FK",[83,762,763,766,773],{},[99,764,765],{"align":88},"One-to-many",[99,767,768,750,771],{"align":88},[43,769,770],{},"course",[43,772,123],{},[99,774,775],{"align":88},"FK на стороне many",[83,777,778,781,787],{},[99,779,780],{"align":88},"Many-to-many",[99,782,783,750,785],{"align":88},[43,784,103],{},[43,786,113],{},[99,788,789],{"align":88},"Таблица связей",[15,791,792],{},"One-to-many:",[64,794,796],{"className":206,"code":795,"language":208,"meta":70,"style":70},"CREATE TABLE lessons (\n    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    course_id bigint NOT NULL REFERENCES courses(id),\n    title     text NOT NULL,\n    position  integer NOT NULL CHECK (position > 0),\n\n    UNIQUE (course_id, position)\n);\n",[43,797,798,809,828,842,853,874,878,885],{"__ignoreMap":70},[212,799,800,802,804,807],{"class":214,"line":215},[212,801,219],{"class":218},[212,803,222],{"class":218},[212,805,806],{"class":225}," lessons",[212,808,230],{"class":229},[212,810,811,814,816,818,820,822,824,826],{"class":214,"line":233},[212,812,813],{"class":229},"    id        ",[212,815,239],{"class":218},[212,817,242],{"class":218},[212,819,245],{"class":218},[212,821,248],{"class":218},[212,823,251],{"class":218},[212,825,254],{"class":218},[212,827,257],{"class":229},[212,829,830,833,835,837,839],{"class":214,"line":260},[212,831,832],{"class":229},"    course_id ",[212,834,239],{"class":218},[212,836,384],{"class":218},[212,838,387],{"class":218},[212,840,841],{"class":229}," courses(id),\n",[212,843,844,847,849,851],{"class":214,"line":284},[212,845,846],{"class":229},"    title     ",[212,848,69],{"class":218},[212,850,384],{"class":218},[212,852,257],{"class":229},[212,854,855,858,861,863,865,868,870,872],{"class":214,"line":302},[212,856,857],{"class":229},"    position  ",[212,859,860],{"class":218},"integer",[212,862,384],{"class":218},[212,864,432],{"class":218},[212,866,867],{"class":229}," (position ",[212,869,438],{"class":218},[212,871,441],{"class":272},[212,873,444],{"class":229},[212,875,876],{"class":214,"line":309},[212,877,306],{"emptyLinePlaceholder":305},[212,879,880,882],{"class":214,"line":324},[212,881,327],{"class":218},[212,883,884],{"class":229}," (course_id, position)\n",[212,886,887],{"class":214,"line":333},[212,888,336],{"class":229},[15,890,891,894,895,898],{},[43,892,893],{},"UNIQUE (course_id, position)"," говорит: внутри одного курса позиция урока уникальна. Но разные курсы могут иметь урок с ",[43,896,897],{},"position = 1",".",[15,900,901],{},"Many-to-many:",[64,903,905],{"className":206,"code":904,"language":208,"meta":70,"style":70},"CREATE TABLE enrollments (\n    user_id   bigint NOT NULL REFERENCES users(id),\n    course_id bigint NOT NULL REFERENCES courses(id),\n    status    text NOT NULL CHECK (status IN ('active', 'paused', 'completed')),\n    joined_at timestamptz NOT NULL DEFAULT now(),\n\n    PRIMARY KEY (user_id, course_id)\n);\n",[43,906,907,918,932,944,984,1000,1004,1012],{"__ignoreMap":70},[212,908,909,911,913,916],{"class":214,"line":215},[212,910,219],{"class":218},[212,912,222],{"class":218},[212,914,915],{"class":225}," enrollments",[212,917,230],{"class":229},[212,919,920,923,925,927,929],{"class":214,"line":233},[212,921,922],{"class":229},"    user_id   ",[212,924,239],{"class":218},[212,926,384],{"class":218},[212,928,387],{"class":218},[212,930,931],{"class":229}," users(id),\n",[212,933,934,936,938,940,942],{"class":214,"line":260},[212,935,832],{"class":229},[212,937,239],{"class":218},[212,939,384],{"class":218},[212,941,387],{"class":218},[212,943,841],{"class":229},[212,945,946,949,952,954,956,959,962,965,967,971,973,976,978,981],{"class":214,"line":284},[212,947,948],{"class":218},"    status",[212,950,951],{"class":218},"    text",[212,953,384],{"class":218},[212,955,432],{"class":218},[212,957,958],{"class":229}," (",[212,960,961],{"class":218},"status",[212,963,964],{"class":218}," IN",[212,966,958],{"class":229},[212,968,970],{"class":969},"sU2Wk","'active'",[212,972,422],{"class":229},[212,974,975],{"class":969},"'paused'",[212,977,422],{"class":229},[212,979,980],{"class":969},"'completed'",[212,982,983],{"class":229},")),\n",[212,985,986,989,991,993,995,997],{"class":214,"line":302},[212,987,988],{"class":229},"    joined_at ",[212,990,484],{"class":218},[212,992,384],{"class":218},[212,994,656],{"class":218},[212,996,659],{"class":218},[212,998,999],{"class":229},"(),\n",[212,1001,1002],{"class":214,"line":309},[212,1003,306],{"emptyLinePlaceholder":305},[212,1005,1006,1009],{"class":214,"line":324},[212,1007,1008],{"class":218},"    PRIMARY KEY",[212,1010,1011],{"class":229}," (user_id, course_id)\n",[212,1013,1014],{"class":214,"line":333},[212,1015,336],{"class":229},[15,1017,1018],{},"Таблица связей почти всегда со временем получает собственные атрибуты: роль, статус, дату вступления, источник, прогресс. Поэтому даже если кажется, что связь \"просто many-to-many\", проектируйте ее как полноценный факт.",[48,1020],{},[56,1022,1024],{"id":1023},"primary-foreign-unique-check","Primary, foreign, unique, check",[15,1026,1027],{},"Четыре constraint'а дают большую часть целостности.",[64,1029,1031],{"className":206,"code":1030,"language":208,"meta":70,"style":70},"CREATE TABLE submissions (\n    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    user_id     bigint NOT NULL REFERENCES users(id),\n    lesson_id   bigint NOT NULL REFERENCES lessons(id),\n    status      text NOT NULL CHECK (status IN ('draft', 'submitted', 'accepted', 'rejected')),\n    attempt_no  integer NOT NULL CHECK (attempt_no > 0),\n    code        text NOT NULL,\n    created_at  timestamptz NOT NULL DEFAULT now(),\n\n    UNIQUE (user_id, lesson_id, attempt_no)\n);\n",[43,1032,1033,1044,1062,1075,1089,1128,1148,1159,1174,1178,1185],{"__ignoreMap":70},[212,1034,1035,1037,1039,1042],{"class":214,"line":215},[212,1036,219],{"class":218},[212,1038,222],{"class":218},[212,1040,1041],{"class":225}," submissions",[212,1043,230],{"class":229},[212,1045,1046,1048,1050,1052,1054,1056,1058,1060],{"class":214,"line":233},[212,1047,554],{"class":229},[212,1049,239],{"class":218},[212,1051,242],{"class":218},[212,1053,245],{"class":218},[212,1055,248],{"class":218},[212,1057,251],{"class":218},[212,1059,254],{"class":218},[212,1061,257],{"class":229},[212,1063,1064,1067,1069,1071,1073],{"class":214,"line":260},[212,1065,1066],{"class":229},"    user_id     ",[212,1068,239],{"class":218},[212,1070,384],{"class":218},[212,1072,387],{"class":218},[212,1074,931],{"class":229},[212,1076,1077,1080,1082,1084,1086],{"class":214,"line":284},[212,1078,1079],{"class":229},"    lesson_id   ",[212,1081,239],{"class":218},[212,1083,384],{"class":218},[212,1085,387],{"class":218},[212,1087,1088],{"class":229}," lessons(id),\n",[212,1090,1091,1093,1096,1098,1100,1102,1104,1106,1108,1111,1113,1116,1118,1121,1123,1126],{"class":214,"line":302},[212,1092,948],{"class":218},[212,1094,1095],{"class":218},"      text",[212,1097,384],{"class":218},[212,1099,432],{"class":218},[212,1101,958],{"class":229},[212,1103,961],{"class":218},[212,1105,964],{"class":218},[212,1107,958],{"class":229},[212,1109,1110],{"class":969},"'draft'",[212,1112,422],{"class":229},[212,1114,1115],{"class":969},"'submitted'",[212,1117,422],{"class":229},[212,1119,1120],{"class":969},"'accepted'",[212,1122,422],{"class":229},[212,1124,1125],{"class":969},"'rejected'",[212,1127,983],{"class":229},[212,1129,1130,1133,1135,1137,1139,1142,1144,1146],{"class":214,"line":309},[212,1131,1132],{"class":229},"    attempt_no  ",[212,1134,860],{"class":218},[212,1136,384],{"class":218},[212,1138,432],{"class":218},[212,1140,1141],{"class":229}," (attempt_no ",[212,1143,438],{"class":218},[212,1145,441],{"class":272},[212,1147,444],{"class":229},[212,1149,1150,1153,1155,1157],{"class":214,"line":324},[212,1151,1152],{"class":229},"    code        ",[212,1154,69],{"class":218},[212,1156,384],{"class":218},[212,1158,257],{"class":229},[212,1160,1161,1164,1166,1168,1170,1172],{"class":214,"line":333},[212,1162,1163],{"class":229},"    created_at  ",[212,1165,484],{"class":218},[212,1167,384],{"class":218},[212,1169,656],{"class":218},[212,1171,659],{"class":218},[212,1173,999],{"class":229},[212,1175,1176],{"class":214,"line":339},[212,1177,306],{"emptyLinePlaceholder":305},[212,1179,1180,1182],{"class":214,"line":344},[212,1181,327],{"class":218},[212,1183,1184],{"class":229}," (user_id, lesson_id, attempt_no)\n",[212,1186,1187],{"class":214,"line":356},[212,1188,336],{"class":229},[15,1190,1191],{},"Что здесь защищено:",[21,1193,1194,1197,1200,1203],{},[24,1195,1196],{},"submission не существует без пользователя и урока;",[24,1198,1199],{},"status не может быть произвольной строкой;",[24,1201,1202],{},"номер попытки положительный;",[24,1204,1205],{},"у пользователя не может быть двух попыток с одинаковым номером для одного урока.",[15,1207,1208],{},"Если это оставить только в Go-коде, любое место, которое пишет в таблицу в обход нужного service-метода, сможет нарушить модель.",[48,1210],{},[56,1212,1214],{"id":1213},"surrogate-vs-natural-keys","Surrogate vs natural keys",[15,1216,1217,1218,422,1221,422,1224,898],{},"Natural key берется из предметной области: email, номер паспорта, ISO-код страны, slug курса. Surrogate key создается системой: ",[43,1219,1220],{},"bigint identity",[43,1222,1223],{},"uuid",[43,1225,1226],{},"ulid",[77,1228,1229,1242],{},[80,1230,1231],{},[83,1232,1233,1236,1239],{},[86,1234,1235],{"align":88},"Критерий",[86,1237,1238],{"align":88},"Natural key",[86,1240,1241],{"align":88},"Surrogate key",[94,1243,1244,1255,1266,1277,1288],{},[83,1245,1246,1249,1252],{},[99,1247,1248],{"align":88},"Смысл для бизнеса",[99,1250,1251],{"align":88},"Есть",[99,1253,1254],{"align":88},"Нет",[83,1256,1257,1260,1263],{},[99,1258,1259],{"align":88},"Может измениться",[99,1261,1262],{"align":88},"Иногда да",[99,1264,1265],{"align":88},"Обычно нет",[83,1267,1268,1271,1274],{},[99,1269,1270],{"align":88},"Размер индекса",[99,1272,1273],{"align":88},"Может быть большим",[99,1275,1276],{"align":88},"Обычно компактный",[83,1278,1279,1282,1285],{},[99,1280,1281],{"align":88},"Удобство ссылок",[99,1283,1284],{"align":88},"Зависит от типа",[99,1286,1287],{"align":88},"Высокое",[83,1289,1290,1293,1296],{},[99,1291,1292],{"align":88},"Риск неверной уникальности",[99,1294,1295],{"align":88},"Выше",[99,1297,1298],{"align":88},"Ниже",[15,1300,1301],{},"Практическое правило:",[21,1303,1304,1307,1313,1316],{},[24,1305,1306],{},"primary key часто делайте surrogate;",[24,1308,1309,1310,1312],{},"бизнес-уникальность фиксируйте отдельным ",[43,1311,759],{},";",[24,1314,1315],{},"не используйте изменяемый атрибут как primary key;",[24,1317,1318],{},"не храните только surrogate key, если есть важная domain uniqueness.",[64,1320,1322],{"className":206,"code":1321,"language":208,"meta":70,"style":70},"CREATE TABLE courses (\n    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    slug        text NOT NULL UNIQUE,\n    title       text NOT NULL,\n    published   boolean NOT NULL DEFAULT false\n);\n",[43,1323,1324,1335,1353,1367,1378,1393],{"__ignoreMap":70},[212,1325,1326,1328,1330,1333],{"class":214,"line":215},[212,1327,219],{"class":218},[212,1329,222],{"class":218},[212,1331,1332],{"class":225}," courses",[212,1334,230],{"class":229},[212,1336,1337,1339,1341,1343,1345,1347,1349,1351],{"class":214,"line":233},[212,1338,554],{"class":229},[212,1340,239],{"class":218},[212,1342,242],{"class":218},[212,1344,245],{"class":218},[212,1346,248],{"class":218},[212,1348,251],{"class":218},[212,1350,254],{"class":218},[212,1352,257],{"class":229},[212,1354,1355,1358,1360,1362,1365],{"class":214,"line":260},[212,1356,1357],{"class":229},"    slug        ",[212,1359,69],{"class":218},[212,1361,384],{"class":218},[212,1363,1364],{"class":218}," UNIQUE",[212,1366,257],{"class":229},[212,1368,1369,1372,1374,1376],{"class":214,"line":284},[212,1370,1371],{"class":229},"    title       ",[212,1373,69],{"class":218},[212,1375,384],{"class":218},[212,1377,257],{"class":229},[212,1379,1380,1383,1386,1388,1390],{"class":214,"line":302},[212,1381,1382],{"class":229},"    published   ",[212,1384,1385],{"class":218},"boolean",[212,1387,384],{"class":218},[212,1389,656],{"class":218},[212,1391,1392],{"class":229}," false\n",[212,1394,1395],{"class":214,"line":309},[212,1396,336],{"class":229},[15,1398,1399,1400,1403,1404,1407],{},"Здесь ",[43,1401,1402],{},"id"," удобен для ссылок, а ",[43,1405,1406],{},"slug"," защищает публичный URL от дублей.",[48,1409],{},[56,1411,1413],{"id":1412},"индексы-концептуально","Индексы концептуально",[15,1415,1416],{},"Индекс - структура данных, которая помогает быстро найти строки, но замедляет запись и занимает место. В PostgreSQL основной тип индекса - B-tree. Он подходит для равенства, диапазонов, сортировки и уникальности.",[64,1418,1420],{"className":206,"code":1419,"language":208,"meta":70,"style":70},"CREATE INDEX idx_orders_user_created_at\nON orders (user_id, created_at DESC);\n",[43,1421,1422,1432],{"__ignoreMap":70},[212,1423,1424,1426,1429],{"class":214,"line":215},[212,1425,219],{"class":218},[212,1427,1428],{"class":218}," INDEX",[212,1430,1431],{"class":225}," idx_orders_user_created_at\n",[212,1433,1434,1437,1440,1443],{"class":214,"line":233},[212,1435,1436],{"class":218},"ON",[212,1438,1439],{"class":229}," orders (user_id, created_at ",[212,1441,1442],{"class":218},"DESC",[212,1444,336],{"class":229},[15,1446,1447],{},"Такой индекс помогает запросу:",[64,1449,1451],{"className":206,"code":1450,"language":208,"meta":70,"style":70},"SELECT *\nFROM orders\nWHERE user_id = $1\nORDER BY created_at DESC\nLIMIT 20;\n",[43,1452,1453,1461,1469,1486,1497],{"__ignoreMap":70},[212,1454,1455,1458],{"class":214,"line":215},[212,1456,1457],{"class":218},"SELECT",[212,1459,1460],{"class":218}," *\n",[212,1462,1463,1466],{"class":214,"line":233},[212,1464,1465],{"class":218},"FROM",[212,1467,1468],{"class":229}," orders\n",[212,1470,1471,1474,1477,1480,1483],{"class":214,"line":260},[212,1472,1473],{"class":218},"WHERE",[212,1475,1476],{"class":229}," user_id ",[212,1478,1479],{"class":218},"=",[212,1481,1482],{"class":229}," $",[212,1484,1485],{"class":272},"1\n",[212,1487,1488,1491,1494],{"class":214,"line":284},[212,1489,1490],{"class":218},"ORDER BY",[212,1492,1493],{"class":229}," created_at ",[212,1495,1496],{"class":218},"DESC\n",[212,1498,1499,1502,1505],{"class":214,"line":302},[212,1500,1501],{"class":218},"LIMIT",[212,1503,1504],{"class":272}," 20",[212,1506,1507],{"class":229},";\n",[15,1509,1510,1511,1514,1515,1518,1519,1522,1523,898],{},"Ключевая идея composite index: порядок колонок важен. Индекс ",[43,1512,1513],{},"(user_id, created_at)"," хорошо работает, когда запрос ограничивает ",[43,1516,1517],{},"user_id",", а потом сортирует или фильтрует по ",[43,1520,1521],{},"created_at",". Он не так же полезен для запроса только по ",[43,1524,1521],{},[15,1526,1527],{},"Частые типы:",[77,1529,1530,1540],{},[80,1531,1532],{},[83,1533,1534,1537],{},[86,1535,1536],{"align":88},"Индекс",[86,1538,1539],{"align":88},"Для чего",[94,1541,1542,1554,1562,1570,1578,1586],{},[83,1543,1544,1547],{},[99,1545,1546],{"align":88},"B-tree",[99,1548,1549,1550,422,1552],{"align":88},"Равенство, диапазоны, ",[43,1551,1490],{},[43,1553,759],{},[83,1555,1556,1559],{},[99,1557,1558],{"align":88},"Hash",[99,1560,1561],{"align":88},"Равенство, используется реже",[83,1563,1564,1567],{},[99,1565,1566],{"align":88},"GIN",[99,1568,1569],{"align":88},"Массивы, JSONB, full-text search",[83,1571,1572,1575],{},[99,1573,1574],{"align":88},"GiST",[99,1576,1577],{"align":88},"Геоданные, ranges, специальные операторы",[83,1579,1580,1583],{},[99,1581,1582],{"align":88},"Partial index",[99,1584,1585],{"align":88},"Индекс только по части строк",[83,1587,1588,1591],{},[99,1589,1590],{"align":88},"Expression index",[99,1592,1593],{"align":88},"Индекс по выражению",[15,1595,1596],{},"Partial index:",[64,1598,1600],{"className":206,"code":1599,"language":208,"meta":70,"style":70},"CREATE INDEX idx_orders_unpaid\nON orders (created_at)\nWHERE status = 'draft';\n",[43,1601,1602,1611,1618],{"__ignoreMap":70},[212,1603,1604,1606,1608],{"class":214,"line":215},[212,1605,219],{"class":218},[212,1607,1428],{"class":218},[212,1609,1610],{"class":225}," idx_orders_unpaid\n",[212,1612,1613,1615],{"class":214,"line":233},[212,1614,1436],{"class":218},[212,1616,1617],{"class":229}," orders (created_at)\n",[212,1619,1620,1622,1625,1628,1631],{"class":214,"line":260},[212,1621,1473],{"class":218},[212,1623,1624],{"class":218}," status",[212,1626,1627],{"class":218}," =",[212,1629,1630],{"class":969}," 'draft'",[212,1632,1507],{"class":229},[15,1634,1635],{},"Expression index:",[64,1637,1639],{"className":206,"code":1638,"language":208,"meta":70,"style":70},"CREATE UNIQUE INDEX idx_users_email_lower\nON users (lower(email));\n",[43,1640,1641,1651],{"__ignoreMap":70},[212,1642,1643,1645,1648],{"class":214,"line":215},[212,1644,219],{"class":218},[212,1646,1647],{"class":218}," UNIQUE INDEX",[212,1649,1650],{"class":225}," idx_users_email_lower\n",[212,1652,1653,1655,1658,1661],{"class":214,"line":233},[212,1654,1436],{"class":218},[212,1656,1657],{"class":229}," users (",[212,1659,1660],{"class":272},"lower",[212,1662,1663],{"class":229},"(email));\n",[15,1665,1666,1667,1670,1671,1674],{},"Теперь ",[43,1668,1669],{},"User@Example.com"," и ",[43,1672,1673],{},"user@example.com"," конфликтуют, если бизнес считает email case-insensitive.",[15,1676,1677],{},"Индекс не надо добавлять \"на всякий случай\". Каждый индекс:",[21,1679,1680,1692,1695,1698],{},[24,1681,1682,1683,422,1686,422,1689,1312],{},"обновляется при ",[43,1684,1685],{},"INSERT",[43,1687,1688],{},"UPDATE",[43,1690,1691],{},"DELETE",[24,1693,1694],{},"занимает место;",[24,1696,1697],{},"может сбивать оптимизатор, если статистика плохая;",[24,1699,1700],{},"усложняет миграции.",[15,1702,1703,1704,898],{},"Обычно индекс появляется из конкретного запроса и проверяется через ",[43,1705,1706],{},"EXPLAIN",[48,1708],{},[51,1710,1712],{"id":1711},"нормализация","Нормализация",[56,1714,1716],{"id":1715},"зачем-она-нужна","Зачем она нужна",[15,1718,1719],{},"Нормализация уменьшает дублирование и защищает от аномалий данных. Это не религиозное правило \"всегда дробить все на максимальное количество таблиц\". Это техника проектирования, которая помогает понять, где один факт хранится в нескольких местах и может разъехаться.",[15,1721,1722],{},"Плохая таблица:",[77,1724,1725,1748],{},[80,1726,1727],{},[83,1728,1729,1733,1736,1739,1742,1745],{},[86,1730,1732],{"align":1731},"right","order_id",[86,1734,1735],{"align":88},"user_email",[86,1737,1738],{"align":88},"user_name",[86,1740,1741],{"align":88},"product_name",[86,1743,1744],{"align":1731},"product_price",[86,1746,1747],{"align":1731},"qty",[94,1749,1750,1773,1791],{},[83,1751,1752,1755,1762,1765,1768,1771],{},[99,1753,1754],{"align":1731},"1",[99,1756,1757],{"align":88},[1758,1759,1761],"a",{"href":1760},"mailto:a@example.com","a@example.com",[99,1763,1764],{"align":88},"Anna",[99,1766,1767],{"align":88},"Go Basics",[99,1769,1770],{"align":1731},"5000",[99,1772,1754],{"align":1731},[83,1774,1775,1777,1781,1783,1786,1789],{},[99,1776,1754],{"align":1731},[99,1778,1779],{"align":88},[1758,1780,1761],{"href":1760},[99,1782,1764],{"align":88},[99,1784,1785],{"align":88},"SQL Pro",[99,1787,1788],{"align":1731},"7000",[99,1790,1754],{"align":1731},[83,1792,1793,1796,1800,1802,1804,1806],{},[99,1794,1795],{"align":1731},"2",[99,1797,1798],{"align":88},[1758,1799,1761],{"href":1760},[99,1801,1764],{"align":88},[99,1803,1767],{"align":88},[99,1805,1770],{"align":1731},[99,1807,1795],{"align":1731},[15,1809,1810],{},"Проблемы:",[21,1812,1813,1816,1819,1822,1825],{},[24,1814,1815],{},"email и имя пользователя повторяются;",[24,1817,1818],{},"цена и название продукта повторяются;",[24,1820,1821],{},"изменение имени пользователя нужно делать в нескольких строках;",[24,1823,1824],{},"нельзя добавить продукт, пока нет заказа;",[24,1826,1827],{},"удаление последнего заказа может удалить знание о продукте.",[15,1829,1830],{},"Нормализованная модель:",[64,1832,1835],{"className":1833,"code":1834,"language":69,"meta":70},[67],"users(id, email, name)\nproducts(id, name, current_price_cents)\norders(id, user_id, created_at)\norder_items(order_id, product_id, price_cents, qty)\n",[43,1836,1834],{"__ignoreMap":70},[15,1838,1839,1840,1843],{},"Важно: ",[43,1841,1842],{},"order_items.price_cents"," может намеренно дублировать цену продукта. Это не ошибка, если это цена на момент покупки. Данные нужно нормализовать по смыслу факта, а не механически.",[48,1845],{},[56,1847,1849],{"id":1848},"functional-dependencies","Functional dependencies",[15,1851,1852,1853,898],{},"Functional dependency: если значение A определяет значение B, пишут ",[43,1854,1855],{},"A -> B",[15,1857,1858],{},"Пример:",[64,1860,1863],{"className":1861,"code":1862,"language":69,"meta":70},[67],"user_id -> email, name\nproduct_id -> product_name, current_price\norder_id -> user_id, created_at\n(order_id, product_id) -> qty, price_at_purchase\n",[43,1864,1862],{"__ignoreMap":70},[15,1866,1867,1868,422,1870,422,1872,1874,1875,1878,1879,1882,1883,1885,1886,1888],{},"Если в таблице есть ",[43,1869,1732],{},[43,1871,1517],{},[43,1873,1735],{},", и ",[43,1876,1877],{},"order_id -> user_id",", а ",[43,1880,1881],{},"user_id -> user_email",", то ",[43,1884,1735],{}," зависит от ",[43,1887,1732],{}," транзитивно. Это сигнал, что email пользователя в заказе может быть лишним дублированием.",[15,1890,1891],{},"Но есть важная оговорка: иногда \"дублирование\" фиксирует исторический факт.",[77,1893,1894,1907],{},[80,1895,1896],{},[83,1897,1898,1901,1904],{},[86,1899,1900],{"align":88},"Поле",[86,1902,1903],{"align":88},"Нормализовать?",[86,1905,1906],{"align":88},"Почему",[94,1908,1909,1924,1937,1949],{},[83,1910,1911,1917,1919],{},[99,1912,1913,1916],{"align":88},[43,1914,1915],{},"orders.user_email"," для текущего email",[99,1918,1265],{"align":88},[99,1920,1921,1922],{"align":88},"Email можно взять через ",[43,1923,103],{},[83,1925,1926,1931,1934],{},[99,1927,1928],{"align":88},[43,1929,1930],{},"orders.contact_email_at_checkout",[99,1932,1933],{"align":88},"Возможно да",[99,1935,1936],{"align":88},"Это исторический email на момент заказа",[83,1938,1939,1944,1946],{},[99,1940,1941],{"align":88},[43,1942,1943],{},"order_items.product_name_snapshot",[99,1945,1933],{"align":88},[99,1947,1948],{"align":88},"Название в чеке должно остаться прежним",[83,1950,1951,1956,1958],{},[99,1952,1953],{"align":88},[43,1954,1955],{},"order_items.current_product_name",[99,1957,1254],{"align":88},[99,1959,1960],{"align":88},"Это не факт строки заказа",[48,1962],{},[56,1964,1966],{"id":1965},"нормальные-формы","Нормальные формы",[56,1968,1970],{"id":1969},"_1nf","1NF",[15,1972,1973],{},"Первая нормальная форма: значения атомарны, нет повторяющихся групп колонок.",[15,1975,1976],{},"Плохо:",[77,1978,1979,1988],{},[80,1980,1981],{},[83,1982,1983,1985],{},[86,1984,1517],{"align":1731},[86,1986,1987],{"align":88},"phones",[94,1989,1990],{},[83,1991,1992,1994],{},[99,1993,1754],{"align":1731},[99,1995,1996],{"align":88},[43,1997,1998],{},"+7999,+7888",[15,2000,2001],{},"Лучше:",[64,2003,2005],{"className":206,"code":2004,"language":208,"meta":70,"style":70},"CREATE TABLE user_phones (\n    user_id bigint NOT NULL REFERENCES users(id),\n    phone   text NOT NULL,\n    PRIMARY KEY (user_id, phone)\n);\n",[43,2006,2007,2018,2031,2042,2049],{"__ignoreMap":70},[212,2008,2009,2011,2013,2016],{"class":214,"line":215},[212,2010,219],{"class":218},[212,2012,222],{"class":218},[212,2014,2015],{"class":225}," user_phones",[212,2017,230],{"class":229},[212,2019,2020,2023,2025,2027,2029],{"class":214,"line":233},[212,2021,2022],{"class":229},"    user_id ",[212,2024,239],{"class":218},[212,2026,384],{"class":218},[212,2028,387],{"class":218},[212,2030,931],{"class":229},[212,2032,2033,2036,2038,2040],{"class":214,"line":260},[212,2034,2035],{"class":229},"    phone   ",[212,2037,69],{"class":218},[212,2039,384],{"class":218},[212,2041,257],{"class":229},[212,2043,2044,2046],{"class":214,"line":284},[212,2045,1008],{"class":218},[212,2047,2048],{"class":229}," (user_id, phone)\n",[212,2050,2051],{"class":214,"line":302},[212,2052,336],{"class":229},[15,2054,2055],{},"Не путайте 1NF с запретом JSON вообще. JSONB может быть разумным для настроек, внешних payload'ов или редко фильтруемых данных. Но если по элементу нужно часто искать, соединять, валидировать и ссылаться внешними ключами, это кандидат на отдельную таблицу.",[56,2057,2059],{"id":2058},"_2nf","2NF",[15,2061,2062],{},"Вторая нормальная форма: таблица в 1NF, и каждый неключевой атрибут зависит от всего составного ключа, а не от его части.",[15,2064,1976],{},[77,2066,2067,2080],{},[80,2068,2069],{},[83,2070,2071,2073,2076,2078],{},[86,2072,1732],{"align":1731},[86,2074,2075],{"align":1731},"product_id",[86,2077,1741],{"align":88},[86,2079,1747],{"align":1731},[94,2081,2082],{},[83,2083,2084,2086,2089,2091],{},[99,2085,1754],{"align":1731},[99,2087,2088],{"align":1731},"10",[99,2090,1767],{"align":88},[99,2092,1754],{"align":1731},[15,2094,2095,2096,2099,2100,2102,2103,2105,2106,898],{},"Ключ ",[43,2097,2098],{},"(order_id, product_id)",", но ",[43,2101,1741],{}," зависит только от ",[43,2104,2075],{},". Значит, название продукта лучше хранить в ",[43,2107,2108],{},"products",[56,2110,2112],{"id":2111},"_3nf","3NF",[15,2114,2115],{},"Третья нормальная форма: нет транзитивных зависимостей неключевых атрибутов от ключа.",[15,2117,1976],{},[77,2119,2120,2132],{},[80,2121,2122],{},[83,2123,2124,2126,2129],{},[86,2125,1517],{"align":1731},[86,2127,2128],{"align":1731},"city_id",[86,2130,2131],{"align":88},"city_name",[94,2133,2134],{},[83,2135,2136,2138,2141],{},[99,2137,1754],{"align":1731},[99,2139,2140],{"align":1731},"77",[99,2142,2143],{"align":88},"Moscow",[15,2145,2146,422,2149,2152,2153,2155,2156,898],{},[43,2147,2148],{},"user_id -> city_id",[43,2150,2151],{},"city_id -> city_name",", значит ",[43,2154,2131],{}," транзитивно зависит от ",[43,2157,1517],{},[15,2159,2001],{},[64,2161,2164],{"className":2162,"code":2163,"language":69,"meta":70},[67],"users(id, city_id)\ncities(id, name)\n",[43,2165,2163],{"__ignoreMap":70},[56,2167,2169],{"id":2168},"bcnf","BCNF",[15,2171,2172,2173,2176],{},"Boyce-Codd Normal Form строже 3NF: для любой нетривиальной functional dependency ",[43,2174,2175],{},"X -> Y"," X должен быть superkey.",[15,2178,2179],{},"Пример проблемы:",[77,2181,2182,2194],{},[80,2183,2184],{},[83,2185,2186,2189,2191],{},[86,2187,2188],{"align":88},"student",[86,2190,770],{"align":88},[86,2192,2193],{"align":88},"instructor",[94,2195,2196,2206],{},[83,2197,2198,2200,2203],{},[99,2199,1764],{"align":88},[99,2201,2202],{"align":88},"Go",[99,2204,2205],{"align":88},"Ivan",[83,2207,2208,2211,2213],{},[99,2209,2210],{"align":88},"Boris",[99,2212,2202],{"align":88},[99,2214,2205],{"align":88},[15,2216,2217,2218,2221,2222,2224],{},"Если правило такое: каждый instructor ведет только один course, и каждый course может иметь несколько instructors, то ",[43,2219,2220],{},"instructor -> course",". Но ",[43,2223,2193],{}," не superkey этой таблицы. Значит, модель может хранить противоречия.",[56,2226,2228],{"id":2227},"_4nf","4NF",[15,2230,2231],{},"Четвертая нормальная форма борется с независимыми многозначными зависимостями.",[15,2233,1976],{},[77,2235,2236,2248],{},[80,2237,2238],{},[83,2239,2240,2242,2245],{},[86,2241,770],{"align":88},[86,2243,2244],{"align":88},"mentor",[86,2246,2247],{"align":88},"language",[94,2249,2250,2260,2269,2278],{},[83,2251,2252,2255,2257],{},[99,2253,2254],{"align":88},"Go Backend",[99,2256,2205],{"align":88},[99,2258,2259],{"align":88},"Russian",[83,2261,2262,2264,2266],{},[99,2263,2254],{"align":88},[99,2265,2205],{"align":88},[99,2267,2268],{"align":88},"English",[83,2270,2271,2273,2276],{},[99,2272,2254],{"align":88},[99,2274,2275],{"align":88},"Maria",[99,2277,2259],{"align":88},[83,2279,2280,2282,2284],{},[99,2281,2254],{"align":88},[99,2283,2275],{"align":88},[99,2285,2268],{"align":88},[15,2287,2288],{},"Если mentors и languages независимы, их нужно хранить отдельно:",[64,2290,2293],{"className":2291,"code":2292,"language":69,"meta":70},[67],"course_mentors(course_id, mentor_id)\ncourse_languages(course_id, language)\n",[43,2294,2292],{"__ignoreMap":70},[56,2296,2298],{"id":2297},"_5nf","5NF",[15,2300,2301],{},"Пятая нормальная форма связана с join dependencies: таблицу можно разложить на меньшие таблицы без потери информации, и все зависимости выражаются через ключи. На практике в backend-собеседованиях редко просят формально доказать 5NF. Важно понимать идею: некоторые многосвязные факты лучше хранить несколькими отношениями, если исходная таблица создает искусственные комбинации.",[48,2303],{},[56,2305,2307],{"id":2306},"аномалии-вставки-обновления-удаления","Аномалии вставки, обновления, удаления",[56,2309,2311],{"id":2310},"аномалия-обновления","Аномалия обновления",[15,2313,2314],{},"Если имя пользователя хранится в каждой строке заказа, изменение имени требует обновить много строк. Одна строка забыта - данные противоречивы.",[56,2316,2318],{"id":2317},"аномалия-вставки","Аномалия вставки",[15,2320,2321],{},"Если продукт хранится только внутри заказа, нельзя создать продукт без заказа.",[56,2323,2325],{"id":2324},"аномалия-удаления","Аномалия удаления",[15,2327,2328],{},"Если удалили последний заказ с продуктом, потеряли информацию о продукте.",[15,2330,2331],{},"Нормализация убирает эти аномалии, потому что каждый факт живет в одном месте.",[48,2333],{},[51,2335,2337],{"id":2336},"денормализация-и-readwrite-модели","Денормализация и read\u002Fwrite модели",[56,2339,2341],{"id":2340},"денормализация","Денормализация",[15,2343,2344],{},"Денормализация - осознанное дублирование данных ради чтения, производительности или автономности. Это не \"мы не знаем нормализацию\", а инженерный компромисс.",[15,2346,2347],{},"Примеры:",[77,2349,2350,2360],{},[80,2351,2352],{},[83,2353,2354,2357],{},[86,2355,2356],{"align":88},"Денормализованное поле",[86,2358,2359],{"align":88},"Зачем",[94,2361,2362,2372,2382,2391],{},[83,2363,2364,2369],{},[99,2365,2366],{"align":88},[43,2367,2368],{},"courses.lessons_count",[99,2370,2371],{"align":88},"Быстро показывать список курсов",[83,2373,2374,2379],{},[99,2375,2376],{"align":88},[43,2377,2378],{},"orders.total_cents",[99,2380,2381],{"align":88},"Не суммировать строки заказа каждый раз",[83,2383,2384,2388],{},[99,2385,2386],{"align":88},[43,2387,1943],{},[99,2389,2390],{"align":88},"Историческая точность чека",[83,2392,2393,2398],{},[99,2394,2395],{"align":88},[43,2396,2397],{},"user_progress.completed_lessons_count",[99,2399,2400],{"align":88},"Быстрый dashboard",[15,2402,2403],{},"Правило: если данные дублируются, должен быть понятен источник истины и механизм синхронизации.",[64,2405,2408],{"className":2406,"code":2407,"language":69,"meta":70},[67],"Источник истины: order_items\nДенормализованное поле: orders.total_cents\nОбновление: в одной транзакции с изменением order_items\nПроверка: периодический consistency job\n",[43,2409,2407],{"__ignoreMap":70},[15,2411,2412],{},"Опасный вариант - когда непонятно, кто главный:",[64,2414,2417],{"className":2415,"code":2416,"language":69,"meta":70},[67],"orders.total_cents\norder_items sum\npayment.amount_cents\ninvoice.total_cents\n\nВсе \"примерно одно и то же\", но расходятся при сбое.\n",[43,2418,2416],{"__ignoreMap":70},[48,2420],{},[56,2422,2424],{"id":2423},"read-model-и-write-model","Read model и write model",[15,2426,2427],{},"Write model оптимизирована под корректные изменения. Read model оптимизирована под чтение.",[15,2429,2430],{},"Для записи:",[64,2432,2435],{"className":2433,"code":2434,"language":69,"meta":70},[67],"users\ncourses\nlessons\nsubmissions\nreviews\n",[43,2436,2434],{"__ignoreMap":70},[15,2438,2439],{},"Для чтения dashboard:",[64,2441,2444],{"className":2442,"code":2443,"language":69,"meta":70},[67],"student_course_progress\n  user_id\n  course_id\n  completed_lessons\n  accepted_submissions\n  last_activity_at\n",[43,2445,2443],{"__ignoreMap":70},[15,2447,2448],{},"Read model можно строить:",[21,2450,2451,2454,2457,2460,2463],{},[24,2452,2453],{},"SQL view;",[24,2455,2456],{},"materialized view;",[24,2458,2459],{},"отдельной таблицей, обновляемой транзакционно;",[24,2461,2462],{},"фоновой задачей;",[24,2464,2465],{},"через event-driven pipeline.",[15,2467,2468],{},"Чем дальше read model от источника истины, тем больше нужно думать про задержку, повторы, idempotency и восстановление.",[48,2470],{},[51,2472,2474],{"id":2473},"паттерны-и-миграции","Паттерны и миграции",[56,2476,2478],{"id":2477},"паттерны-проектирования-схем","Паттерны проектирования схем",[56,2480,2482],{"id":2481},"lookup-table-вместо-свободной-строки","Lookup table вместо свободной строки",[15,2484,2485,2486,898],{},"Если список значений управляется бизнесом и расширяется, отдельная таблица лучше ",[43,2487,2488],{},"CHECK",[64,2490,2492],{"className":206,"code":2491,"language":208,"meta":70,"style":70},"CREATE TABLE lesson_statuses (\n    code text PRIMARY KEY,\n    title text NOT NULL\n);\n\nCREATE TABLE lessons (\n    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    status text NOT NULL REFERENCES lesson_statuses(code)\n);\n",[43,2493,2494,2505,2516,2526,2530,2534,2544,2563,2577],{"__ignoreMap":70},[212,2495,2496,2498,2500,2503],{"class":214,"line":215},[212,2497,219],{"class":218},[212,2499,222],{"class":218},[212,2501,2502],{"class":225}," lesson_statuses",[212,2504,230],{"class":229},[212,2506,2507,2510,2512,2514],{"class":214,"line":233},[212,2508,2509],{"class":229},"    code ",[212,2511,69],{"class":218},[212,2513,254],{"class":218},[212,2515,257],{"class":229},[212,2517,2518,2521,2523],{"class":214,"line":260},[212,2519,2520],{"class":229},"    title ",[212,2522,69],{"class":218},[212,2524,2525],{"class":218}," NOT NULL\n",[212,2527,2528],{"class":214,"line":284},[212,2529,336],{"class":229},[212,2531,2532],{"class":214,"line":302},[212,2533,306],{"emptyLinePlaceholder":305},[212,2535,2536,2538,2540,2542],{"class":214,"line":309},[212,2537,219],{"class":218},[212,2539,222],{"class":218},[212,2541,806],{"class":225},[212,2543,230],{"class":229},[212,2545,2546,2549,2551,2553,2555,2557,2559,2561],{"class":214,"line":324},[212,2547,2548],{"class":229},"    id ",[212,2550,239],{"class":218},[212,2552,242],{"class":218},[212,2554,245],{"class":218},[212,2556,248],{"class":218},[212,2558,251],{"class":218},[212,2560,254],{"class":218},[212,2562,257],{"class":229},[212,2564,2565,2567,2570,2572,2574],{"class":214,"line":333},[212,2566,948],{"class":218},[212,2568,2569],{"class":218}," text",[212,2571,384],{"class":218},[212,2573,387],{"class":218},[212,2575,2576],{"class":229}," lesson_statuses(code)\n",[212,2578,2579],{"class":214,"line":339},[212,2580,336],{"class":229},[15,2582,2583,2584,2586],{},"Если список маленький и меняется только с релизом, ",[43,2585,2488],{}," может быть проще.",[56,2588,2590],{"id":2589},"audit-columns","Audit columns",[64,2592,2594],{"className":206,"code":2593,"language":208,"meta":70,"style":70},"created_at timestamptz NOT NULL DEFAULT now(),\nupdated_at timestamptz NOT NULL DEFAULT now(),\ncreated_by bigint REFERENCES users(id),\nupdated_by bigint REFERENCES users(id)\n",[43,2595,2596,2611,2626,2637],{"__ignoreMap":70},[212,2597,2598,2601,2603,2605,2607,2609],{"class":214,"line":215},[212,2599,2600],{"class":229},"created_at ",[212,2602,484],{"class":218},[212,2604,384],{"class":218},[212,2606,656],{"class":218},[212,2608,659],{"class":218},[212,2610,999],{"class":229},[212,2612,2613,2616,2618,2620,2622,2624],{"class":214,"line":233},[212,2614,2615],{"class":229},"updated_at ",[212,2617,484],{"class":218},[212,2619,384],{"class":218},[212,2621,656],{"class":218},[212,2623,659],{"class":218},[212,2625,999],{"class":229},[212,2627,2628,2631,2633,2635],{"class":214,"line":260},[212,2629,2630],{"class":229},"created_by ",[212,2632,239],{"class":218},[212,2634,387],{"class":218},[212,2636,931],{"class":229},[212,2638,2639,2642,2644,2646],{"class":214,"line":284},[212,2640,2641],{"class":229},"updated_by ",[212,2643,239],{"class":218},[212,2645,387],{"class":218},[212,2647,2648],{"class":229}," users(id)\n",[15,2650,2651,2654],{},[43,2652,2653],{},"updated_at"," обычно требует явного обновления из приложения или trigger. Не добавляйте колонку, если никто не гарантирует ее актуальность.",[56,2656,2658],{"id":2657},"soft-delete","Soft delete",[64,2660,2662],{"className":206,"code":2661,"language":208,"meta":70,"style":70},"deleted_at timestamptz\n",[43,2663,2664],{"__ignoreMap":70},[212,2665,2666,2669],{"class":214,"line":215},[212,2667,2668],{"class":229},"deleted_at ",[212,2670,2671],{"class":218},"timestamptz\n",[15,2673,2674],{},"Плюсы:",[21,2676,2677,2680,2683],{},[24,2678,2679],{},"можно восстановить данные;",[24,2681,2682],{},"проще аудит;",[24,2684,2685],{},"ссылки не ломаются.",[15,2687,2688],{},"Минусы:",[21,2690,2691,2697,2700,2703],{},[24,2692,2693,2694,1312],{},"каждый запрос должен учитывать ",[43,2695,2696],{},"deleted_at IS NULL",[24,2698,2699],{},"unique constraints усложняются;",[24,2701,2702],{},"таблицы растут;",[24,2704,2705],{},"очистка данных становится отдельной задачей.",[15,2707,2708],{},"Partial unique index для soft delete:",[64,2710,2712],{"className":206,"code":2711,"language":208,"meta":70,"style":70},"CREATE UNIQUE INDEX users_email_active_unique\nON users (lower(email))\nWHERE deleted_at IS NULL;\n",[43,2713,2714,2723,2734],{"__ignoreMap":70},[212,2715,2716,2718,2720],{"class":214,"line":215},[212,2717,219],{"class":218},[212,2719,1647],{"class":218},[212,2721,2722],{"class":225}," users_email_active_unique\n",[212,2724,2725,2727,2729,2731],{"class":214,"line":233},[212,2726,1436],{"class":218},[212,2728,1657],{"class":229},[212,2730,1660],{"class":272},[212,2732,2733],{"class":229},"(email))\n",[212,2735,2736,2738,2741,2744,2747],{"class":214,"line":260},[212,2737,1473],{"class":218},[212,2739,2740],{"class":229}," deleted_at ",[212,2742,2743],{"class":218},"IS",[212,2745,2746],{"class":218}," NULL",[212,2748,1507],{"class":229},[56,2750,2752],{"id":2751},"status-history","Status history",[15,2754,2755],{},"Если важно знать историю статусов, одной колонки мало.",[64,2757,2759],{"className":206,"code":2758,"language":208,"meta":70,"style":70},"CREATE TABLE order_status_events (\n    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,\n    order_id   bigint NOT NULL REFERENCES orders(id),\n    old_status text,\n    new_status text NOT NULL,\n    changed_at timestamptz NOT NULL DEFAULT now(),\n    changed_by bigint REFERENCES users(id)\n);\n",[43,2760,2761,2772,2791,2805,2814,2825,2840,2851],{"__ignoreMap":70},[212,2762,2763,2765,2767,2770],{"class":214,"line":215},[212,2764,219],{"class":218},[212,2766,222],{"class":218},[212,2768,2769],{"class":225}," order_status_events",[212,2771,230],{"class":229},[212,2773,2774,2777,2779,2781,2783,2785,2787,2789],{"class":214,"line":233},[212,2775,2776],{"class":229},"    id         ",[212,2778,239],{"class":218},[212,2780,242],{"class":218},[212,2782,245],{"class":218},[212,2784,248],{"class":218},[212,2786,251],{"class":218},[212,2788,254],{"class":218},[212,2790,257],{"class":229},[212,2792,2793,2796,2798,2800,2802],{"class":214,"line":260},[212,2794,2795],{"class":229},"    order_id   ",[212,2797,239],{"class":218},[212,2799,384],{"class":218},[212,2801,387],{"class":218},[212,2803,2804],{"class":229}," orders(id),\n",[212,2806,2807,2810,2812],{"class":214,"line":284},[212,2808,2809],{"class":229},"    old_status ",[212,2811,69],{"class":218},[212,2813,257],{"class":229},[212,2815,2816,2819,2821,2823],{"class":214,"line":302},[212,2817,2818],{"class":229},"    new_status ",[212,2820,69],{"class":218},[212,2822,384],{"class":218},[212,2824,257],{"class":229},[212,2826,2827,2830,2832,2834,2836,2838],{"class":214,"line":309},[212,2828,2829],{"class":229},"    changed_at ",[212,2831,484],{"class":218},[212,2833,384],{"class":218},[212,2835,656],{"class":218},[212,2837,659],{"class":218},[212,2839,999],{"class":229},[212,2841,2842,2845,2847,2849],{"class":214,"line":324},[212,2843,2844],{"class":229},"    changed_by ",[212,2846,239],{"class":218},[212,2848,387],{"class":218},[212,2850,2648],{"class":229},[212,2852,2853],{"class":214,"line":333},[212,2854,336],{"class":229},[15,2856,2857,2860,2861,2864],{},[43,2858,2859],{},"orders.status"," хранит текущее состояние для быстрых запросов, ",[43,2862,2863],{},"order_status_events"," хранит историю.",[56,2866,2868],{"id":2867},"outbox","Outbox",[15,2870,2871],{},"Если нужно изменить БД и надежно отправить событие, событие сначала пишут в outbox в той же транзакции.",[64,2873,2875],{"className":206,"code":2874,"language":208,"meta":70,"style":70},"CREATE TABLE outbox_events (\n    id            uuid PRIMARY KEY,\n    aggregate_id  text NOT NULL,\n    event_type    text NOT NULL,\n    payload       jsonb NOT NULL,\n    created_at    timestamptz NOT NULL DEFAULT now(),\n    published_at  timestamptz\n);\n",[43,2876,2877,2888,2898,2909,2920,2929,2944,2951],{"__ignoreMap":70},[212,2878,2879,2881,2883,2886],{"class":214,"line":215},[212,2880,219],{"class":218},[212,2882,222],{"class":218},[212,2884,2885],{"class":225}," outbox_events",[212,2887,230],{"class":229},[212,2889,2890,2893,2896],{"class":214,"line":233},[212,2891,2892],{"class":229},"    id            uuid ",[212,2894,2895],{"class":218},"PRIMARY KEY",[212,2897,257],{"class":229},[212,2899,2900,2903,2905,2907],{"class":214,"line":260},[212,2901,2902],{"class":229},"    aggregate_id  ",[212,2904,69],{"class":218},[212,2906,384],{"class":218},[212,2908,257],{"class":229},[212,2910,2911,2914,2916,2918],{"class":214,"line":284},[212,2912,2913],{"class":229},"    event_type    ",[212,2915,69],{"class":218},[212,2917,384],{"class":218},[212,2919,257],{"class":229},[212,2921,2922,2925,2927],{"class":214,"line":302},[212,2923,2924],{"class":229},"    payload       jsonb ",[212,2926,279],{"class":218},[212,2928,257],{"class":229},[212,2930,2931,2934,2936,2938,2940,2942],{"class":214,"line":309},[212,2932,2933],{"class":229},"    created_at    ",[212,2935,484],{"class":218},[212,2937,384],{"class":218},[212,2939,656],{"class":218},[212,2941,659],{"class":218},[212,2943,999],{"class":229},[212,2945,2946,2949],{"class":214,"line":324},[212,2947,2948],{"class":229},"    published_at  ",[212,2950,2671],{"class":218},[212,2952,2953],{"class":214,"line":333},[212,2954,336],{"class":229},[15,2956,2957],{},"Фоновый publisher читает неопубликованные события и отправляет их в Kafka, Redis Streams или другой broker. Это снижает риск \"данные сохранили, событие потеряли\".",[48,2959],{},[56,2961,2963],{"id":2962},"миграции","Миграции",[15,2965,2966],{},"Миграция - изменение схемы или справочных данных, которое можно применить к базе.",[15,2968,2969],{},"Хорошая миграция:",[21,2971,2972,2975,2978,2981,2984,2987],{},[24,2973,2974],{},"маленькая;",[24,2976,2977],{},"идемпотентная там, где это уместно;",[24,2979,2980],{},"совместима с текущей и следующей версией приложения;",[24,2982,2983],{},"имеет понятный rollback-план;",[24,2985,2986],{},"учитывает блокировки и объем таблиц;",[24,2988,2989],{},"не смешивает много несвязанных изменений.",[56,2991,2993],{"id":2992},"expand-contract","Expand-contract",[15,2995,2996],{},"Для production часто используют подход expand-contract:",[2998,2999,3000,3003,3006,3009,3012],"ol",{},[24,3001,3002],{},"Добавить новую nullable-колонку или таблицу.",[24,3004,3005],{},"Выпустить приложение, которое пишет и старое, и новое.",[24,3007,3008],{},"Backfill существующих данных.",[24,3010,3011],{},"Переключить чтение на новое поле.",[24,3013,3014,3015,422,3017,3019],{},"Добавить ",[43,3016,279],{},[43,3018,759],{},", FK или удалить старое поле.",[15,3021,1858],{},[64,3023,3025],{"className":206,"code":3024,"language":208,"meta":70,"style":70},"-- 1. Быстрое расширение схемы.\nALTER TABLE users ADD COLUMN display_name text;\n\n-- 2. Backfill отдельной задачей или батчами.\nUPDATE users\nSET display_name = name\nWHERE display_name IS NULL;\n\n-- 3. После проверки.\nALTER TABLE users ALTER COLUMN display_name SET NOT NULL;\n",[43,3026,3027,3033,3053,3057,3062,3069,3082,3094,3098,3103],{"__ignoreMap":70},[212,3028,3029],{"class":214,"line":215},[212,3030,3032],{"class":3031},"sAwPA","-- 1. Быстрое расширение схемы.\n",[212,3034,3035,3038,3040,3043,3046,3049,3051],{"class":214,"line":233},[212,3036,3037],{"class":218},"ALTER",[212,3039,222],{"class":218},[212,3041,3042],{"class":229}," users ",[212,3044,3045],{"class":218},"ADD",[212,3047,3048],{"class":229}," COLUMN display_name ",[212,3050,69],{"class":218},[212,3052,1507],{"class":229},[212,3054,3055],{"class":214,"line":260},[212,3056,306],{"emptyLinePlaceholder":305},[212,3058,3059],{"class":214,"line":284},[212,3060,3061],{"class":3031},"-- 2. Backfill отдельной задачей или батчами.\n",[212,3063,3064,3066],{"class":214,"line":302},[212,3065,1688],{"class":218},[212,3067,3068],{"class":229}," users\n",[212,3070,3071,3074,3077,3079],{"class":214,"line":309},[212,3072,3073],{"class":218},"SET",[212,3075,3076],{"class":229}," display_name ",[212,3078,1479],{"class":218},[212,3080,3081],{"class":218}," name\n",[212,3083,3084,3086,3088,3090,3092],{"class":214,"line":324},[212,3085,1473],{"class":218},[212,3087,3076],{"class":229},[212,3089,2743],{"class":218},[212,3091,2746],{"class":218},[212,3093,1507],{"class":229},[212,3095,3096],{"class":214,"line":333},[212,3097,306],{"emptyLinePlaceholder":305},[212,3099,3100],{"class":214,"line":339},[212,3101,3102],{"class":3031},"-- 3. После проверки.\n",[212,3104,3105,3107,3109,3111,3113,3115,3117,3119],{"class":214,"line":344},[212,3106,3037],{"class":218},[212,3108,222],{"class":218},[212,3110,3042],{"class":229},[212,3112,3037],{"class":218},[212,3114,3048],{"class":229},[212,3116,3073],{"class":218},[212,3118,384],{"class":218},[212,3120,1507],{"class":229},[15,3122,3123,3124,3126],{},"На больших таблицах backfill одним ",[43,3125,1688],{}," может создать долгую транзакцию, раздуть WAL и заблокировать важные операции. Лучше делать батчами.",[56,3128,3130],{"id":3129},"constraint-без-долгой-блокировки","Constraint без долгой блокировки",[15,3132,3133,3134,3137],{},"В PostgreSQL для некоторых сценариев удобно добавлять constraint как ",[43,3135,3136],{},"NOT VALID",", а потом валидировать отдельно:",[64,3139,3141],{"className":206,"code":3140,"language":208,"meta":70,"style":70},"ALTER TABLE orders\nADD CONSTRAINT orders_user_id_fk\nFOREIGN KEY (user_id) REFERENCES users(id)\nNOT VALID;\n\nALTER TABLE orders\nVALIDATE CONSTRAINT orders_user_id_fk;\n",[43,3142,3143,3151,3161,3174,3182,3186,3194],{"__ignoreMap":70},[212,3144,3145,3147,3149],{"class":214,"line":215},[212,3146,3037],{"class":218},[212,3148,222],{"class":218},[212,3150,1468],{"class":229},[212,3152,3153,3155,3158],{"class":214,"line":233},[212,3154,3045],{"class":218},[212,3156,3157],{"class":218}," CONSTRAINT",[212,3159,3160],{"class":229}," orders_user_id_fk\n",[212,3162,3163,3166,3169,3172],{"class":214,"line":260},[212,3164,3165],{"class":218},"FOREIGN KEY",[212,3167,3168],{"class":229}," (user_id) ",[212,3170,3171],{"class":218},"REFERENCES",[212,3173,2648],{"class":229},[212,3175,3176,3179],{"class":214,"line":284},[212,3177,3178],{"class":218},"NOT",[212,3180,3181],{"class":229}," VALID;\n",[212,3183,3184],{"class":214,"line":302},[212,3185,306],{"emptyLinePlaceholder":305},[212,3187,3188,3190,3192],{"class":214,"line":309},[212,3189,3037],{"class":218},[212,3191,222],{"class":218},[212,3193,1468],{"class":229},[212,3195,3196,3199,3202],{"class":214,"line":324},[212,3197,3198],{"class":229},"VALIDATE ",[212,3200,3201],{"class":218},"CONSTRAINT",[212,3203,3204],{"class":229}," orders_user_id_fk;\n",[15,3206,3207],{},"Это не магическая кнопка \"без блокировок вообще\", но полезный инструмент для больших таблиц.",[56,3209,3211],{"id":3210},"миграционная-безопасность-backfill-rollback-validation","Миграционная безопасность: backfill, rollback, validation",[15,3213,3214],{},"Production-миграция должна отвечать не только на вопрос \"какой SQL выполнить\", но и на вопрос \"что будет, если релиз остановится посередине\". Особенно опасны изменения, которые одновременно меняют схему, код записи, код чтения и исторические данные.",[15,3216,3217],{},"Перед backfill полезно явно зафиксировать:",[77,3219,3220,3229],{},[80,3221,3222],{},[83,3223,3224,3227],{},[86,3225,3226],{"align":88},"Вопрос",[86,3228,2359],{"align":88},[94,3230,3231,3239,3247,3255,3272,3280],{},[83,3232,3233,3236],{},[99,3234,3235],{"align":88},"Какой source of truth для старых данных?",[99,3237,3238],{"align":88},"Чтобы не заполнить новое поле приблизительным значением",[83,3240,3241,3244],{},[99,3242,3243],{"align":88},"Можно ли запускать backfill повторно?",[99,3245,3246],{"align":88},"Retry не должен создавать дубли или менять уже проверенные строки",[83,3248,3249,3252],{},[99,3250,3251],{"align":88},"Какой размер батча и пауза между батчами?",[99,3253,3254],{"align":88},"Чтобы не раздуть WAL и не забить autovacuum\u002Freplication",[83,3256,3257,3260],{},[99,3258,3259],{"align":88},"Какие запросы проверяют прогресс?",[99,3261,3262,3263,422,3266,422,3269],{"align":88},"Нужны понятные метрики ",[43,3264,3265],{},"remaining",[43,3267,3268],{},"updated",[43,3270,3271],{},"failed",[83,3273,3274,3277],{},[99,3275,3276],{"align":88},"Что делает старая версия приложения?",[99,3278,3279],{"align":88},"Expand-contract требует совместимости между релизами",[83,3281,3282,3285],{},[99,3283,3284],{"align":88},"Как откатиться?",[99,3286,3287,3288,3291],{"align":88},"Иногда rollback - это не ",[43,3289,3290],{},"DROP COLUMN",", а возврат чтения на старое поле",[15,3293,3294],{},"Пример безопасного направления мысли:",[64,3296,3298],{"className":206,"code":3297,"language":208,"meta":70,"style":70},"-- 1. Добавляем поле без жесткого ограничения.\nALTER TABLE rate_decisions ADD COLUMN rule_version integer;\n\n-- 2. Новая версия приложения начинает писать rule_version.\n-- 3. Backfill идет батчами и может быть перезапущен.\nUPDATE rate_decisions\nSET rule_version = 1\nWHERE rule_version IS NULL\n  AND id >= $1\n  AND id \u003C $2;\n\n-- 4. Проверяем, что дыр больше нет.\nSELECT count(*) AS missing_rule_version\nFROM rate_decisions\nWHERE rule_version IS NULL;\n\n-- 5. Только после проверки усиливаем constraint.\nALTER TABLE rate_decisions\nALTER COLUMN rule_version SET NOT NULL;\n",[43,3299,3300,3305,3323,3327,3332,3337,3344,3356,3367,3381,3396,3400,3405,3425,3431,3443,3447,3452,3460],{"__ignoreMap":70},[212,3301,3302],{"class":214,"line":215},[212,3303,3304],{"class":3031},"-- 1. Добавляем поле без жесткого ограничения.\n",[212,3306,3307,3309,3311,3314,3316,3319,3321],{"class":214,"line":233},[212,3308,3037],{"class":218},[212,3310,222],{"class":218},[212,3312,3313],{"class":229}," rate_decisions ",[212,3315,3045],{"class":218},[212,3317,3318],{"class":229}," COLUMN rule_version ",[212,3320,860],{"class":218},[212,3322,1507],{"class":229},[212,3324,3325],{"class":214,"line":260},[212,3326,306],{"emptyLinePlaceholder":305},[212,3328,3329],{"class":214,"line":284},[212,3330,3331],{"class":3031},"-- 2. Новая версия приложения начинает писать rule_version.\n",[212,3333,3334],{"class":214,"line":302},[212,3335,3336],{"class":3031},"-- 3. Backfill идет батчами и может быть перезапущен.\n",[212,3338,3339,3341],{"class":214,"line":309},[212,3340,1688],{"class":218},[212,3342,3343],{"class":229}," rate_decisions\n",[212,3345,3346,3348,3351,3353],{"class":214,"line":324},[212,3347,3073],{"class":218},[212,3349,3350],{"class":229}," rule_version ",[212,3352,1479],{"class":218},[212,3354,3355],{"class":272}," 1\n",[212,3357,3358,3360,3362,3364],{"class":214,"line":333},[212,3359,1473],{"class":218},[212,3361,3350],{"class":229},[212,3363,2743],{"class":218},[212,3365,3366],{"class":218}," NULL\n",[212,3368,3369,3372,3375,3377,3379],{"class":214,"line":339},[212,3370,3371],{"class":218},"  AND",[212,3373,3374],{"class":229}," id ",[212,3376,515],{"class":218},[212,3378,1482],{"class":229},[212,3380,1485],{"class":272},[212,3382,3383,3385,3387,3390,3392,3394],{"class":214,"line":344},[212,3384,3371],{"class":218},[212,3386,3374],{"class":229},[212,3388,3389],{"class":218},"\u003C",[212,3391,1482],{"class":229},[212,3393,1795],{"class":272},[212,3395,1507],{"class":229},[212,3397,3398],{"class":214,"line":356},[212,3399,306],{"emptyLinePlaceholder":305},[212,3401,3402],{"class":214,"line":376},[212,3403,3404],{"class":3031},"-- 4. Проверяем, что дыр больше нет.\n",[212,3406,3407,3409,3412,3414,3417,3419,3422],{"class":214,"line":393},[212,3408,1457],{"class":218},[212,3410,3411],{"class":272}," count",[212,3413,269],{"class":229},[212,3415,3416],{"class":218},"*",[212,3418,276],{"class":229},[212,3420,3421],{"class":218},"AS",[212,3423,3424],{"class":229}," missing_rule_version\n",[212,3426,3427,3429],{"class":214,"line":408},[212,3428,1465],{"class":218},[212,3430,3343],{"class":229},[212,3432,3433,3435,3437,3439,3441],{"class":214,"line":447},[212,3434,1473],{"class":218},[212,3436,3350],{"class":229},[212,3438,2743],{"class":218},[212,3440,2746],{"class":218},[212,3442,1507],{"class":229},[212,3444,3445],{"class":214,"line":478},[212,3446,306],{"emptyLinePlaceholder":305},[212,3448,3449],{"class":214,"line":491},[212,3450,3451],{"class":3031},"-- 5. Только после проверки усиливаем constraint.\n",[212,3453,3454,3456,3458],{"class":214,"line":503},[212,3455,3037],{"class":218},[212,3457,222],{"class":218},[212,3459,3343],{"class":229},[212,3461,3462,3464,3466,3468,3470],{"class":214,"line":508},[212,3463,3037],{"class":218},[212,3465,3318],{"class":229},[212,3467,3073],{"class":218},[212,3469,384],{"class":218},[212,3471,1507],{"class":229},[15,3473,3474,3475,3478],{},"Rollback-план здесь может быть таким: если новая версия приложения работает плохо, старая версия продолжает читать прежние поля, а ",[43,3476,3477],{},"rule_version"," остается безвредным расширением схемы. Удаление колонки можно отложить на отдельную contract-миграцию после стабилизации.",[15,3480,3481],{},"Reviewer в PR с миграцией обычно смотрит не только SQL-файл, но и порядок релизов:",[21,3483,3484,3487,3496,3499,3502,3505,3508],{},[24,3485,3486],{},"новая схема совместима со старым кодом?",[24,3488,3489,3490,3492,3493,3495],{},"старые данные валидируются до добавления ",[43,3491,279],{},"\u002FFK\u002F",[43,3494,759],{},"?",[24,3497,3498],{},"backfill батчевый и идемпотентный?",[24,3500,3501],{},"есть ли запрос проверки результата?",[24,3503,3504],{},"понятен ли rollback без потери данных?",[24,3506,3507],{},"не появится ли долгий lock на горячей таблице?",[24,3509,3510],{},"обновлены ли read\u002Fwrite paths так, чтобы не было расхождения source of truth?",[48,3512],{},[51,3514,3516],{"id":3515},"как-проектировать-схему-practically","Как проектировать схему practically",[2998,3518,3519,3522,3525,3528,3531,3534,3537,3540,3543,3546],{},[24,3520,3521],{},"Выпишите сценарии записи: создание, изменение, удаление, смена статуса.",[24,3523,3524],{},"Выпишите основные сценарии чтения: список, карточка, dashboard, админка, отчеты.",[24,3526,3527],{},"Найдите сущности и связи.",[24,3529,3530],{},"Определите ключи и бизнес-уникальность.",[24,3532,3533],{},"Добавьте constraints для простых инвариантов.",[24,3535,3536],{},"Нормализуйте повторяющиеся факты.",[24,3538,3539],{},"Добавьте индексы под реальные запросы.",[24,3541,3542],{},"Отдельно решите, где нужна денормализация.",[24,3544,3545],{},"Спроектируйте миграции с учетом существующих данных.",[24,3547,3548,3549,898],{},"Проверьте критичные запросы через ",[43,3550,1706],{},[48,3552],{},[51,3554,3556],{"id":3555},"собеседование-как-отвечать","Собеседование: как отвечать",[56,3558,3560],{"id":3559},"что-такое-нормализация","Что такое нормализация?",[15,3562,3563],{},"Это процесс организации данных так, чтобы уменьшить дублирование и исключить аномалии вставки, обновления и удаления. Обычно обсуждают 1NF, 2NF, 3NF, BCNF и дальше.",[56,3565,3567],{"id":3566},"всегда-ли-нормализация-хороша","Всегда ли нормализация хороша?",[15,3569,3570],{},"Нет. Нормализованная write model хороша для целостности, но некоторые read-сценарии требуют денормализации: счетчики, snapshots, materialized views, read models. Важно понимать источник истины и механизм синхронизации.",[56,3572,3574],{"id":3573},"что-выбрать-natural-key-или-surrogate-key","Что выбрать: natural key или surrogate key?",[15,3576,3577,3578,3580],{},"Часто primary key делают surrogate, потому что он стабилен и компактен. Natural key фиксируют через ",[43,3579,759],{},", если он отражает бизнес-уникальность. Изменяемый natural key как PK обычно создает боль со ссылками.",[56,3582,3584],{"id":3583},"почему-индекс-не-всегда-ускоряет","Почему индекс не всегда ускоряет?",[15,3586,3587,3588,3590],{},"Индекс помогает только определенным запросам, но замедляет запись, занимает место и может быть бесполезен при низкой селективности или неподходящем порядке колонок. Нужен ",[43,3589,1706],{},", статистика и понимание конкретного запроса.",[56,3592,3594,3595,3597],{"id":3593},"как-безопасно-добавить-not-null-колонку","Как безопасно добавить ",[43,3596,279],{}," колонку?",[15,3599,3600,3601,3603,3604,3606],{},"Обычно: добавить nullable-колонку, начать писать значение, сделать backfill, проверить отсутствие ",[43,3602,45],{},", затем добавить ",[43,3605,279],{},". На большой таблице важно не держать долгую транзакцию и не блокировать production.",[48,3608],{},[51,3610,3612],{"id":3611},"что-запомнить","Что запомнить",[21,3614,3615,3618,3623,3626,3629],{},[24,3616,3617],{},"Нормализация уменьшает дублирование и защищает от аномалий изменения данных.",[24,3619,3620,3621,898],{},"Surrogate primary key удобен как техническая ссылка, natural key часто фиксируют через ",[43,3622,759],{},[24,3624,3625],{},"Денормализация допустима, если известен source of truth и механизм синхронизации.",[24,3627,3628],{},"Индекс проектируют под запрос, а не \"на всякий случай\".",[24,3630,3631],{},"Миграции больших таблиц требуют expand-contract, backfill батчами и проверки locks.",[48,3633],{},[51,3635,3637],{"id":3636},"практика","Практика",[2998,3639,3640,3643,3646,3649],{},[24,3641,3642],{},"Возьмите сущности учебной платформы и выпишите functional dependencies: курс, модуль, урок, пользователь, прогресс.",[24,3644,3645],{},"Найдите пример нарушения 1NF\u002F2NF\u002F3NF и разложите таблицу.",[24,3647,3648],{},"Добавьте read model для dashboard прогресса и опишите, откуда она обновляется.",[24,3650,3651,3652,898],{},"Спланируйте безопасную миграцию добавления обязательного ",[43,3653,3654],{},"display_name",[48,3656],{},[51,3658,3660],{"id":3659},"интерактивная-практика","Интерактивная практика",[3662,3663,3665,3668,3685],"quiz",{"answer":273,"id":3664,"xp":2088},"db-normalization-q1",[15,3666,3667],{},"Что лучше всего описывает безопасную денормализацию?",[3669,3670,3671],"template",{"v-slot:options":70},[21,3672,3673,3676,3679,3682],{},[24,3674,3675],{},"Скопировать данные в новую таблицу и забыть source of truth",[24,3677,3678],{},"Удалить constraints, чтобы writes стали быстрее",[24,3680,3681],{},"Явно назвать source of truth и способ синхронизации read model",[24,3683,3684],{},"Всегда хранить всё в JSONB, чтобы не думать о схеме",[3669,3686,3687],{"v-slot:explanation":70},[15,3688,3689],{},"Денормализация допустима, когда понятно, какие данные главные, кто обновляет копию и как чинить рассинхрон.",[3691,3692,3696,3699,3811],"predict",{"answer":3693,"id":3694,"xp":3695},"normalized\\nread-model","db-normalization-p1","15",[15,3697,3698],{},"Что вернёт запрос?",[3669,3700,3701],{"v-slot:code":70},[64,3702,3704],{"className":206,"code":3703,"language":208,"meta":70,"style":70},"WITH tables(name, source_of_truth) AS (\n    VALUES\n        ('courses', true),\n        ('course_progress_dashboard', false)\n)\nSELECT CASE\n    WHEN source_of_truth THEN 'normalized'\n    ELSE 'read-model'\nEND AS role\nFROM tables\nORDER BY source_of_truth DESC;\n",[43,3705,3706,3724,3729,3740,3750,3755,3762,3776,3784,3794,3801],{"__ignoreMap":70},[212,3707,3708,3711,3714,3717,3720,3722],{"class":214,"line":215},[212,3709,3710],{"class":218},"WITH",[212,3712,3713],{"class":229}," tables(",[212,3715,3716],{"class":218},"name",[212,3718,3719],{"class":229},", source_of_truth) ",[212,3721,3421],{"class":218},[212,3723,230],{"class":229},[212,3725,3726],{"class":214,"line":233},[212,3727,3728],{"class":218},"    VALUES\n",[212,3730,3731,3734,3737],{"class":214,"line":260},[212,3732,3733],{"class":229},"        (",[212,3735,3736],{"class":969},"'courses'",[212,3738,3739],{"class":229},", true),\n",[212,3741,3742,3744,3747],{"class":214,"line":284},[212,3743,3733],{"class":229},[212,3745,3746],{"class":969},"'course_progress_dashboard'",[212,3748,3749],{"class":229},", false)\n",[212,3751,3752],{"class":214,"line":302},[212,3753,3754],{"class":229},")\n",[212,3756,3757,3759],{"class":214,"line":309},[212,3758,1457],{"class":218},[212,3760,3761],{"class":218}," CASE\n",[212,3763,3764,3767,3770,3773],{"class":214,"line":324},[212,3765,3766],{"class":218},"    WHEN",[212,3768,3769],{"class":229}," source_of_truth ",[212,3771,3772],{"class":218},"THEN",[212,3774,3775],{"class":969}," 'normalized'\n",[212,3777,3778,3781],{"class":214,"line":333},[212,3779,3780],{"class":218},"    ELSE",[212,3782,3783],{"class":969}," 'read-model'\n",[212,3785,3786,3789,3791],{"class":214,"line":339},[212,3787,3788],{"class":218},"END",[212,3790,248],{"class":218},[212,3792,3793],{"class":218}," role\n",[212,3795,3796,3798],{"class":214,"line":344},[212,3797,1465],{"class":218},[212,3799,3800],{"class":229}," tables\n",[212,3802,3803,3805,3807,3809],{"class":214,"line":356},[212,3804,1490],{"class":218},[212,3806,3769],{"class":229},[212,3808,1442],{"class":218},[212,3810,1507],{"class":229},[3669,3812,3813],{"v-slot:hint":70},[15,3814,3815],{},"Write model обычно защищает инварианты, read model оптимизирует конкретное чтение.",[48,3817],{},[51,3819,3821],{"id":3820},"полезные-источники","Полезные источники",[21,3823,3824,3833,3840],{},[24,3825,3826,3827],{},"PostgreSQL Documentation: ",[1758,3828,3832],{"href":3829,"rel":3830},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fddl-constraints.html",[3831],"nofollow","Constraints",[24,3834,3826,3835],{},[1758,3836,3839],{"href":3837,"rel":3838},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Findexes.html",[3831],"Indexes",[24,3841,3826,3842],{},[1758,3843,3846],{"href":3844,"rel":3845},"https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fddl-alter.html",[3831],"Modifying Tables",[3848,3849,3850],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}",{"title":70,"searchDepth":233,"depth":233,"links":3852},[3853,3861,3876,3880,3892,3893,3901,3902,3903,3904],{"id":53,"depth":233,"text":54,"children":3854},[3855,3856,3857,3858,3859,3860],{"id":58,"depth":260,"text":59},{"id":187,"depth":260,"text":188},{"id":718,"depth":260,"text":719},{"id":1023,"depth":260,"text":1024},{"id":1213,"depth":260,"text":1214},{"id":1412,"depth":260,"text":1413},{"id":1711,"depth":233,"text":1712,"children":3862},[3863,3864,3865,3866,3867,3868,3869,3870,3871,3872,3873,3874,3875],{"id":1715,"depth":260,"text":1716},{"id":1848,"depth":260,"text":1849},{"id":1965,"depth":260,"text":1966},{"id":1969,"depth":260,"text":1970},{"id":2058,"depth":260,"text":2059},{"id":2111,"depth":260,"text":2112},{"id":2168,"depth":260,"text":2169},{"id":2227,"depth":260,"text":2228},{"id":2297,"depth":260,"text":2298},{"id":2306,"depth":260,"text":2307},{"id":2310,"depth":260,"text":2311},{"id":2317,"depth":260,"text":2318},{"id":2324,"depth":260,"text":2325},{"id":2336,"depth":233,"text":2337,"children":3877},[3878,3879],{"id":2340,"depth":260,"text":2341},{"id":2423,"depth":260,"text":2424},{"id":2473,"depth":233,"text":2474,"children":3881},[3882,3883,3884,3885,3886,3887,3888,3889,3890,3891],{"id":2477,"depth":260,"text":2478},{"id":2481,"depth":260,"text":2482},{"id":2589,"depth":260,"text":2590},{"id":2657,"depth":260,"text":2658},{"id":2751,"depth":260,"text":2752},{"id":2867,"depth":260,"text":2868},{"id":2962,"depth":260,"text":2963},{"id":2992,"depth":260,"text":2993},{"id":3129,"depth":260,"text":3130},{"id":3210,"depth":260,"text":3211},{"id":3515,"depth":233,"text":3516},{"id":3555,"depth":233,"text":3556,"children":3894},[3895,3896,3897,3898,3899],{"id":3559,"depth":260,"text":3560},{"id":3566,"depth":260,"text":3567},{"id":3573,"depth":260,"text":3574},{"id":3583,"depth":260,"text":3584},{"id":3593,"depth":260,"text":3900},"Как безопасно добавить NOT NULL колонку?",{"id":3611,"depth":233,"text":3612},{"id":3636,"depth":233,"text":3637},{"id":3659,"depth":233,"text":3660},{"id":3820,"depth":233,"text":3821},1781022065423]