[{"data":1,"prerenderedAt":1019},["ShallowReactive",2],{"page:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_routing_sql":3,"translations:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_routing_sql":981,"series:ru:cuando":986},{"id":4,"title":5,"author":6,"body":7,"date":965,"description":966,"draft":967,"extension":968,"image":969,"listed":970,"meta":971,"navigation":970,"part":972,"path":973,"seo":974,"series":975,"stem":976,"tags":977,"theme":975,"toc":970,"__hash__":980},"content_ru\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_routing_sql.md","Запрос, который съел базу","slnt_opp",{"type":8,"value":9,"toc":958},"minimark",[10,19,22,27,30,38,163,166,170,173,700,705,739,742,757,761,764,767,794,797,801,804,815,822,825,829,849,954],[11,12,13,14,18],"p",{},"В конце первой части я пообещал поиск пути: «Как добраться из пункта А в пункт Б?» — максимум с двумя пересадками и коротким пешим переходом между остановками, если так быстрее, чем ждать. На сервере он уже был — ",[15,16,17],"em",{},"«написано это, как ты уже догадался, ещё одним рекурсивным запросом»",", — а приложение его пока не вызывало.",[11,20,21],{},"Этой осенью приложение начало его вызывать. А с сегодняшнего дня поиск пути обходится без SQL. Это история о шести неделях посередине, и она не столько про автобусы, сколько про то, что бывает, когда запрос, который работает на ноутбуке, встречается с базой, которая живёт где-то в другом месте.",[23,24,26],"h2",{"id":25},"ходьба-тоже-маршрут","Ходьба — тоже маршрут",[11,28,29],{},"Сеть из первой части — это остановки и рёбра: «маршрут A едет от C\u002F Mayor до Petanca за минуту сорок». Но чтобы спланировать поездку, приходится ещё и ходить пешком: от того места, где ты сейчас, до остановки, от одной остановки до другой, чтобы сменить маршрут, и от последней остановки до того места, куда тебе надо.",[11,31,32,33,37],{},"Первый и последний начинаются или заканчиваются не на остановке, так что их считает пешеходный роутер. А вот средний — это фокус. Небольшой SQL-скрипт перебирает все пары остановок меньше чем в 500 метрах друг от друга, между которыми ещё нет ребра, и соединяет их ребром на маршруте под названием ",[34,35,36],"code",{},"walk()",":",[39,40,45],"pre",{"className":41,"code":42,"language":43,"meta":44,"style":44},"language-sql shiki shiki-themes monokai","SELECT a, b, 'walk()' AS line,\n       PRINTF(\"%g\", ROUND(distance * 3600 \u002F 3)) || 's' AS duration -- 3 km\u002Fh walking speed with \"obstacles\" (e.g. traffic lights)\nFROM ( … the distance between two stops, as great-circle maths in SQL … )\n","sql","",[34,46,47,74,124],{"__ignoreMap":44},[48,49,52,56,60,64,67,71],"span",{"class":50,"line":51},"line",1,[48,53,55],{"class":54},"s8I7P","SELECT",[48,57,59],{"class":58},"sCdxs"," a, b, ",[48,61,63],{"class":62},"s_Ekj","'walk()'",[48,65,66],{"class":54}," AS",[48,68,70],{"class":69},"sOx1s"," line",[48,72,73],{"class":58},",\n",[48,75,77,80,83,86,90,93,96,100,103,106,109,112,115,117,120],{"class":50,"line":76},2,[48,78,79],{"class":58},"       PRINTF(",[48,81,82],{"class":62},"\"%g\"",[48,84,85],{"class":58},", ",[48,87,89],{"class":88},"sYf5A","ROUND",[48,91,92],{"class":58},"(distance ",[48,94,95],{"class":54},"*",[48,97,99],{"class":98},"s7s5_"," 3600",[48,101,102],{"class":54}," \u002F",[48,104,105],{"class":98}," 3",[48,107,108],{"class":58},")) ",[48,110,111],{"class":54},"||",[48,113,114],{"class":62}," 's'",[48,116,66],{"class":54},[48,118,119],{"class":58}," duration ",[48,121,123],{"class":122},"snpHw","-- 3 km\u002Fh walking speed with \"obstacles\" (e.g. traffic lights)\n",[48,125,127,130,133,136,139,142,145,148,151,154,157,160],{"class":50,"line":126},3,[48,128,129],{"class":54},"FROM",[48,131,132],{"class":58}," ( … the distance ",[48,134,135],{"class":54},"between",[48,137,138],{"class":58}," two stops, ",[48,140,141],{"class":54},"as",[48,143,144],{"class":58}," great",[48,146,147],{"class":54},"-",[48,149,150],{"class":69},"circle",[48,152,153],{"class":58}," maths ",[48,155,156],{"class":54},"in",[48,158,159],{"class":54}," SQL",[48,161,162],{"class":58}," … )\n",[11,164,165],{},"Три километра в час — это вроде бы медленно, пока не вспомнишь про светофоры, кольца и августовское солнце. А дальше пеший переход — просто ещё одно ребро со своей длительностью, и поиску незачем отличать его от автобусного. Пеших рёбер в таблице больше, чем автобусных: на сентябрь около шестисот против четырёхсот.",[23,167,169],{"id":168},"запрос","Запрос",[11,171,172],{},"Поиск пути — это двоюродный брат запроса из первой части, который считал время прибытия, только с амбициями. Он начинает с одной остановки, идёт по рёбрам и запоминает, где уже побывал. Здесь он слегка урезан, но это та самая версия, которая работала до сегодняшнего дня:",[39,174,176],{"className":41,"code":175,"language":43,"meta":44,"style":44},"WITH RECURSIVE dfs(station_id, path, path_lines, current_line, depth, line_changed, stations_on_current_line) AS (\n  SELECT e.`from`, CAST(e.`from` AS TEXT), CAST(e.`line` AS TEXT), e.`line`, 0, 0, 1\n  FROM route_edges e WHERE e.`from` = {:from}\n  UNION ALL\n  SELECT e.`to`,\n         dfs.path || CASE WHEN e.`line` != dfs.current_line THEN ';switch()' ELSE '' END || ';' || e.`to`,\n         …,\n         e.`line`, dfs.depth + 1,\n         CASE WHEN e.`line` = dfs.current_line THEN dfs.line_changed ELSE dfs.line_changed + 1 END,\n         CASE WHEN e.`line` = dfs.current_line THEN dfs.stations_on_current_line + 1 ELSE 1 END\n  FROM dfs JOIN route_edges e ON e.`from` = dfs.station_id\n  WHERE dfs.depth \u003C {:depth}\n    AND instr(dfs.path, e.`to`) = 0\n    AND (e.`line` = dfs.current_line\n         OR (dfs.line_changed \u003C 2 AND dfs.stations_on_current_line >= 2))\n)\nSELECT station_id, path_lines, depth, path FROM dfs\nWHERE station_id = {:to} AND stations_on_current_line >= 2\nORDER BY depth, \u003Cnumber of lines> LIMIT {:limit};\n",[34,177,178,201,260,287,293,305,368,374,400,445,484,513,531,560,579,616,622,638,668],{"__ignoreMap":44},[48,179,180,183,186,189,192,195,198],{"class":50,"line":51},[48,181,182],{"class":54},"WITH",[48,184,185],{"class":54}," RECURSIVE",[48,187,188],{"class":58}," dfs(station_id, ",[48,190,191],{"class":69},"path",[48,193,194],{"class":58},", path_lines, current_line, depth, line_changed, stations_on_current_line) ",[48,196,197],{"class":54},"AS",[48,199,200],{"class":58}," (\n",[48,202,203,206,209,212,214,217,220,222,224,227,230,232,234,237,239,241,244,246,248,251,253,255,257],{"class":50,"line":76},[48,204,205],{"class":54},"  SELECT",[48,207,208],{"class":58}," e.",[48,210,211],{"class":62},"`from`",[48,213,85],{"class":58},[48,215,216],{"class":88},"CAST",[48,218,219],{"class":58},"(e.",[48,221,211],{"class":62},[48,223,66],{"class":54},[48,225,226],{"class":69}," TEXT",[48,228,229],{"class":58},"), ",[48,231,216],{"class":88},[48,233,219],{"class":58},[48,235,236],{"class":62},"`line`",[48,238,66],{"class":54},[48,240,226],{"class":69},[48,242,243],{"class":58},"), e.",[48,245,236],{"class":62},[48,247,85],{"class":58},[48,249,250],{"class":98},"0",[48,252,85],{"class":58},[48,254,250],{"class":98},[48,256,85],{"class":58},[48,258,259],{"class":98},"1\n",[48,261,262,265,268,271,273,275,278,281,284],{"class":50,"line":126},[48,263,264],{"class":54},"  FROM",[48,266,267],{"class":58}," route_edges e ",[48,269,270],{"class":54},"WHERE",[48,272,208],{"class":58},[48,274,211],{"class":62},[48,276,277],{"class":54}," =",[48,279,280],{"class":58}," {:",[48,282,283],{"class":54},"from",[48,285,286],{"class":58},"}\n",[48,288,290],{"class":50,"line":289},4,[48,291,292],{"class":54},"  UNION ALL\n",[48,294,296,298,300,303],{"class":50,"line":295},5,[48,297,205],{"class":54},[48,299,208],{"class":58},[48,301,302],{"class":62},"`to`",[48,304,73],{"class":58},[48,306,308,311,314,316,319,322,325,327,329,332,335,337,340,343,346,349,352,355,357,360,362,364,366],{"class":50,"line":307},6,[48,309,310],{"class":98},"         dfs",[48,312,313],{"class":58},".",[48,315,191],{"class":98},[48,317,318],{"class":54}," ||",[48,320,321],{"class":54}," CASE",[48,323,324],{"class":54}," WHEN",[48,326,208],{"class":58},[48,328,236],{"class":62},[48,330,331],{"class":54}," !=",[48,333,334],{"class":98}," dfs",[48,336,313],{"class":58},[48,338,339],{"class":98},"current_line",[48,341,342],{"class":54}," THEN",[48,344,345],{"class":62}," ';switch()'",[48,347,348],{"class":54}," ELSE",[48,350,351],{"class":62}," ''",[48,353,354],{"class":54}," END",[48,356,318],{"class":54},[48,358,359],{"class":62}," ';'",[48,361,318],{"class":54},[48,363,208],{"class":58},[48,365,302],{"class":62},[48,367,73],{"class":58},[48,369,371],{"class":50,"line":370},7,[48,372,373],{"class":58},"         …,\n",[48,375,377,380,382,384,387,389,392,395,398],{"class":50,"line":376},8,[48,378,379],{"class":58},"         e.",[48,381,236],{"class":62},[48,383,85],{"class":58},[48,385,386],{"class":98},"dfs",[48,388,313],{"class":58},[48,390,391],{"class":98},"depth",[48,393,394],{"class":54}," +",[48,396,397],{"class":98}," 1",[48,399,73],{"class":58},[48,401,403,406,408,410,412,414,416,418,420,422,424,426,429,431,433,435,437,439,441,443],{"class":50,"line":402},9,[48,404,405],{"class":54},"         CASE",[48,407,324],{"class":54},[48,409,208],{"class":58},[48,411,236],{"class":62},[48,413,277],{"class":54},[48,415,334],{"class":98},[48,417,313],{"class":58},[48,419,339],{"class":98},[48,421,342],{"class":54},[48,423,334],{"class":98},[48,425,313],{"class":58},[48,427,428],{"class":98},"line_changed",[48,430,348],{"class":54},[48,432,334],{"class":98},[48,434,313],{"class":58},[48,436,428],{"class":98},[48,438,394],{"class":54},[48,440,397],{"class":98},[48,442,354],{"class":54},[48,444,73],{"class":58},[48,446,448,450,452,454,456,458,460,462,464,466,468,470,473,475,477,479,481],{"class":50,"line":447},10,[48,449,405],{"class":54},[48,451,324],{"class":54},[48,453,208],{"class":58},[48,455,236],{"class":62},[48,457,277],{"class":54},[48,459,334],{"class":98},[48,461,313],{"class":58},[48,463,339],{"class":98},[48,465,342],{"class":54},[48,467,334],{"class":98},[48,469,313],{"class":58},[48,471,472],{"class":98},"stations_on_current_line",[48,474,394],{"class":54},[48,476,397],{"class":98},[48,478,348],{"class":54},[48,480,397],{"class":98},[48,482,483],{"class":54}," END\n",[48,485,487,489,492,495,497,500,502,504,506,508,510],{"class":50,"line":486},11,[48,488,264],{"class":54},[48,490,491],{"class":58}," dfs ",[48,493,494],{"class":54},"JOIN",[48,496,267],{"class":58},[48,498,499],{"class":54},"ON",[48,501,208],{"class":58},[48,503,211],{"class":62},[48,505,277],{"class":54},[48,507,334],{"class":98},[48,509,313],{"class":58},[48,511,512],{"class":98},"station_id\n",[48,514,516,519,521,523,525,528],{"class":50,"line":515},12,[48,517,518],{"class":54},"  WHERE",[48,520,334],{"class":98},[48,522,313],{"class":58},[48,524,391],{"class":98},[48,526,527],{"class":54}," \u003C",[48,529,530],{"class":58}," {:depth}\n",[48,532,534,537,540,542,544,546,549,551,554,557],{"class":50,"line":533},13,[48,535,536],{"class":54},"    AND",[48,538,539],{"class":58}," instr(",[48,541,386],{"class":98},[48,543,313],{"class":58},[48,545,191],{"class":98},[48,547,548],{"class":58},", e.",[48,550,302],{"class":62},[48,552,553],{"class":58},") ",[48,555,556],{"class":54},"=",[48,558,559],{"class":98}," 0\n",[48,561,563,565,568,570,572,574,576],{"class":50,"line":562},14,[48,564,536],{"class":54},[48,566,567],{"class":58}," (e.",[48,569,236],{"class":62},[48,571,277],{"class":54},[48,573,334],{"class":98},[48,575,313],{"class":58},[48,577,578],{"class":98},"current_line\n",[48,580,582,585,588,590,592,594,596,599,602,604,606,608,611,613],{"class":50,"line":581},15,[48,583,584],{"class":54},"         OR",[48,586,587],{"class":58}," (",[48,589,386],{"class":98},[48,591,313],{"class":58},[48,593,428],{"class":98},[48,595,527],{"class":54},[48,597,598],{"class":98}," 2",[48,600,601],{"class":54}," AND",[48,603,334],{"class":98},[48,605,313],{"class":58},[48,607,472],{"class":98},[48,609,610],{"class":54}," >=",[48,612,598],{"class":98},[48,614,615],{"class":58},"))\n",[48,617,619],{"class":50,"line":618},16,[48,620,621],{"class":58},")\n",[48,623,625,627,630,632,635],{"class":50,"line":624},17,[48,626,55],{"class":54},[48,628,629],{"class":58}," station_id, path_lines, depth, ",[48,631,191],{"class":69},[48,633,634],{"class":54}," FROM",[48,636,637],{"class":58}," dfs\n",[48,639,641,643,646,648,650,653,656,659,662,665],{"class":50,"line":640},18,[48,642,270],{"class":54},[48,644,645],{"class":58}," station_id ",[48,647,556],{"class":54},[48,649,280],{"class":58},[48,651,652],{"class":54},"to",[48,654,655],{"class":58},"} ",[48,657,658],{"class":54},"AND",[48,660,661],{"class":58}," stations_on_current_line ",[48,663,664],{"class":54},">=",[48,666,667],{"class":98}," 2\n",[48,669,671,674,677,680,683,686,689,692,694,697],{"class":50,"line":670},19,[48,672,673],{"class":54},"ORDER BY",[48,675,676],{"class":58}," depth, ",[48,678,679],{"class":54},"\u003C",[48,681,682],{"class":69},"number",[48,684,685],{"class":58}," of lines",[48,687,688],{"class":54},">",[48,690,691],{"class":54}," LIMIT",[48,693,280],{"class":58},[48,695,696],{"class":54},"limit",[48,698,699],{"class":58},"};\n",[11,701,702,703,37],{},"Каждая строка — это недостроенный путь: список пройденных остановок в виде текста, задействованные маршруты и число пересадок. Все правила сидят в ",[34,704,270],{},[706,707,708,728,733],"ul",{},[709,710,711,715,716,719,720,723,724,727],"li",{},[712,713,714],"strong",{},"Никогда не заезжать на одну остановку дважды."," ",[34,717,718],{},"instr(path, stop) = 0"," — это поиск подстроки в пути. Топорно: стоит пути проехать ",[34,721,722],{},"panorama-2",", как ",[34,725,726],{},"panorama"," тоже считается пройденной. Но в основном работает.",[709,729,730],{},[712,731,732],{},"Не больше двух пересадок.",[709,734,735,738],{},[712,736,737],{},"Перед пересадкой проехать хотя бы две остановки."," Без этого правила поиск с радостью садится в автобус на одну остановку, чтобы на следующей пересесть.",[11,740,741],{},"Поиск не знает и того, до какой остановки ты пойдёшь пешком. Он берёт три ближайшие к тебе остановки и три ближайшие к тому месту, куда ты едешь: девять пар. Для каждой пары он запрашивает пути глубиной до 5 рёбер. Если набирается меньше четырёх вариантов, он пробует 10, потом 15 и так вплоть до 50.",[11,743,744,745,748,749,752,753,756],{},"Плейсхолдеры вроде ",[34,746,747],{},"{:from}"," — это синтаксис PocketBase, как и ",[34,750,751],{},"{{route_edges}}",", который выше я для простоты написал обычным ",[34,754,755],{},"route_edges",". На нашем сервере их подставляет сам PocketBase, а в Worker то же самое делают несколько строчек кода, так что наши файлы с запросами работают и там, и там. Я этим, признаться, гордился. Сам поиск пути, правда, так никогда и не запускался нигде, кроме Worker.",[23,758,760],{"id":759},"всё-работало-потом-мы-натравили-на-него-приложение","Всё работало. Потом мы натравили на него приложение",[11,762,763],{},"Впервые запрос появился в октябре 2024 года — как файл, который я запускал руками, — а в ноябре получил эндпоинт в коммите под названием «navigation alpha». Приложение его ни разу не вызвало.",[11,765,766],{},"В августе наш Flutter-разработчик превратил его в полноценный метод API, который начинает с двух точек на карте, а не с двух id остановок. В октябре к нему подключили экран навигации, который за лето собрали на моковых данных, и дев-сборки начали задавать настоящие вопросы — из настоящих мест в настоящие места. Проблемы начались на той же неделе:",[706,768,769,775,784],{},[709,770,771,774],{},[712,772,773],{},"Конец октября."," Первая оптимизация: прежде чем пересесть, путь должен проехать хотя бы две остановки. Для людей правило разумное, а поиск оно заметно сужает.",[709,776,777,780,781,313],{},[712,778,779],{},"Конец ноября."," Метод получил маршрут в продовом API, и через день у D1 уже заканчивалась память. Каждый поиск отправлял все девять пар остановок разом. Починили это тем, что стали отправлять их по одной, а рядом оставили комментарий с нашей тогдашней версией происходящего: ",[15,782,783],{},"«искать пути ПОСЛЕДОВАТЕЛЬНО, чтобы не ловить от D1 ошибки нехватки памяти из-за слишком большого числа одновременных рекурсивных CTE-запросов»",[709,785,786,789,790,793],{},[712,787,788],{},"1 декабря."," Гипотеза: память съедает сама рекурсия, которая уходит вглубь по одной ветке за другой. Поэтому появилась версия запроса с поиском в ширину и комментарием сверху: ",[15,791,792],{},"«Подход BFS: итеративные запросы вместо рекурсивного CTE. Так мы избегаем взрывного роста памяти при глубокой рекурсии»",". Тот же коммит расширил поиск до четырёх ближайших остановок с каждого конца — шестнадцать пар — и ограничил глубину тридцатью. Ничего заметного всё это не изменило.",[11,795,796],{},"D1 — это SQLite на стороне Cloudflare, и ограничения там такие, каких у ноутбука нет: на запрос даётся 30 секунд, а памяти куда меньше, чем у ноутбука, — сколько именно, Cloudflare не говорит. На маленькой сети и для короткой поездки наш запрос чувствовал себя нормально. Через весь город на глубину в десять рёбер — уже нет.",[23,798,800],{"id":799},"с-сегодняшнего-дня-граф-живёт-в-worker","С сегодняшнего дня граф живёт в Worker",[11,802,803],{},"Так что сегодня мы перестали спрашивать у SQL.",[11,805,806,807,814],{},"Остановки и рёбра теперь складываются в снапшот — protobuf-файл в R2. Когда файл впервые нужен Worker, тот загружает его в ",[808,809,813],"a",{"href":810,"rel":811},"https:\u002F\u002Fgraphology.github.io",[812],"nofollow","graphology",", строит из него простую табличку — какая остановка куда ведёт — и держит её в памяти до конца своей жизни. Сам поиск — это цикл со стеком: берёшь путь, продлеваешь его по каждому разрешённому ребру и кладёшь то, что вышло, обратно на стек. Правила — те же три, что были в SQL. А D1 по-прежнему отвечает на дешёвые вопросы вроде «какие остановки рядом с этой точкой» и «когда следующий автобус», и отвечает за миллисекунды.",[11,816,817,818,821],{},"Судя по новым логам производительности, поиск теперь занимает ",[712,819,820],{},"0,00 мс",". Я решил им верить.",[11,823,824],{},"Урок, который я из этого выношу: SQL был правильным инструментом, чтобы «сложить срез вектора», и неправильным, чтобы «перечислить все способы добраться отсюда туда». Тысяча рёбер — крошечная таблица, а запрос, который помещается на один экран, — крошечный запрос. А вот число путей крошечным не назовёшь.",[23,826,828],{"id":827},"что-дальше","Что дальше",[706,830,831,837],{},[709,832,833,836],{},[712,834,835],{},"Ночная джоба,"," которая пересобирает снапшот из базы, чтобы никому не приходилось об этом помнить.",[709,838,839,842,843,848],{},[712,840,841],{},"Автобусы в настоящем роутинг-движке."," Пешеходную навигацию нам уже даёт ",[808,844,847],{"href":845,"rel":846},"https:\u002F\u002Fgithub.com\u002Fvalhalla\u002Fvalhalla",[812],"Valhalla"," — роутинг-движок, который мы хостим сами. Общественный транспорт он тоже умеет, если скормить ему GTFS-файл: стандартный формат расписаний, который для Торревьехи, насколько мы знаем, никто не публикует.",[850,851,852,858],"note",{},[11,853,854,857],{},[712,855,856],{},"Из сентября 2026."," Этот пост датирован днём, когда поиск пути ушёл из D1, и написан так, как всё выглядело тогда. С высоты прошедших месяцев — и наконец-то с замерами:",[706,859,860,870,887,896,910,924,930,936,942],{},[709,861,862,865,866,869],{},[712,863,864],{},"Замерили — с опозданием на девять месяцев."," Мы восстановили из git сеть образца сентября 2025 года и прогнали поиск на локальной копии D1, которая отдаёт тот же счётчик прочитанных строк, по которому D1 выставляет счёт. Медианный запрос от одной остановки на глубину в пять рёбер читал около 5 800 строк. На глубину в десять рёбер — около 556 000, а от центра города — 51,8 миллиона. Медианный пользовательский поиск читал около ",[712,867,868],{},"2,2 миллиона строк",", средний — около 4,1 миллиона, и это ещё оценка снизу. Бесплатных 5 миллионов строк в день хватает на один-два поиска. На платном тарифе тысяча поисков в день обошлась бы примерно в $99 в месяц сверх базовых $5. Насколько мы можем судить, этих денег так никто и не заплатил: ни один релиз в сторах не вышел с этим поиском до того, как он переехал. Последние три дня, с шестнадцатью парами остановок вместо девяти, каждый поиск обходился примерно в 1,8 раза дороже.",[709,871,872,875,876,878,879,882],{},[712,873,874],{},"Поиском в глубину он не был никогда."," Рекурсивный CTE в SQLite складывает необработанные строки в очередь, если только не дать рекурсивной части ",[34,877,673],{},", так что наш всё это время искал в ширину. Почти наверняка отсюда и нехватка памяти: поиск в ширину держит весь фронт обхода сразу, а в десяти рёбрах от автовокзала это около 650 МБ — в несколько раз больше тех 128 МБ, что достаются изоляту Cloudflare. Это самое близкое к лимиту, что Cloudflare вообще документирует. А переписанная 1 декабря версия так ни разу и не запустилась. Правка ушла в отрендеренную копию запроса, которую мы держали для отладки, а Worker загружает шаблон. Поиск в памяти, который пришёл на смену им обоим, — первый настоящий поиск в глубину в этом посте, что вполне символично.",[880,881],"br",{},[883,884],"img",{"alt":885,"src":886},"Сколько путей одновременно в работе на каждой глубине одного запроса от C\u002F Mayor, сеть образца сентября 2025 года: поначалу горстка, около 141 000 на глубине 10 и 2,56 миллиона на глубине 15","\u002Fimages\u002Fcuando_routing_sql\u002Ffrontier-ru.png",[709,888,889,892,893,895],{},[712,890,891],{},"Виноваты пешие переходы."," Все пешие рёбра сидят на одном и том же «маршруте» ",[34,894,36],{},", так что ходьба от остановки к остановке и дальше пересадкой не считалась, а из-за правила двух остановок пешая пересадка требовала как минимум двух пеших шагов. Внутри паутины пеших рёбер поиск мог гулять как угодно. Если её убрать, запрос на глубину в десять рёбер сжимается в 220 раз по медиане. Сама таблица проблемой никогда не была: SQLite строит временный индекс, так что каждый запрос читает её целиком всего дважды. В августе 2026 года пеший переход стал пересадкой — такой же, как смена автобуса.",[709,897,898,901,902,905,906,909],{},[712,899,900],{},"Логи врали."," На следующий день поиск в памяти завис, и ему поставили таймаут в три секунды. Сработать этот таймаут не мог никогда. Чтобы защититься от атак по времени, Workers замораживают часы, пока код работает без всякого ввода-вывода, так что ",[34,903,904],{},"Date.now()"," и ",[34,907,908],{},"performance.now()"," возвращают одно и то же значение, сколько бы ни крутился цикл. Все «0,00 мс» в наших логах — это и были замёрзшие часы. В августе 2026 года один запрос сжёг 32,5 секунды процессорного времени, прежде чем умер. Теперь поиск останавливается после фиксированного числа шагов, общего на весь запрос.",[709,911,912,915,916,919,920,923],{},[712,913,914],{},"Ночная джоба появилась через два дня"," и записывала ",[34,917,918],{},"navigation-graph1.pb",". А Worker читает ",[34,921,922],{},"navigation-graph.pb",". Так что восемь месяцев, до августа 2026 года, ночная пересборка до прода не доезжала.",[709,925,926,929],{},[712,927,928],{},"Автобусы и правда переехали в Valhalla"," — в августе 2026 года, с GTFS-фидом, который мы генерируем сами. Об этом — пятая часть серии.",[709,931,932,935],{},[712,933,934],{},"А тот векторный запрос, на который, по словам первой части, «любая база отвечает моментально»?"," На него пришлось около 80% всего, что читалось из D1. У таблицы, которую он фильтрует, вообще не было индекса, так что каждый раз, когда приложение его дёргало, она читалась целиком. А поздно вечером он мог даже заявить, что автобусов больше нет: он отбирал шестнадцать ближайших отправлений ещё до того, как разделить их на прошедшие и будущие, и в половине двенадцатого на маршруте, где автобус ходит каждые десять минут, все шестнадцать оказывались в прошлом. С конца сентября 2026 года табло каждого маршрута — это маленький заранее посчитанный файл в Cloudflare KV, который пересобирается при каждом изменении расписания, а запрос читает около двух строк.",[709,937,938,941],{},[712,939,940],{},"А потом — следующий."," Когда табло ушли из D1, в списке самых тяжёлых запросов Cloudflare сменился лидер: поиск остановок, около четырёх миллионов строк в день. Он гонял представление, которое соединяет все остановки, маршруты и рёбра и группирует их, и пересобирал его целиком на каждое нажатие клавиши. Вторым, с 1,25 миллиона, шёл тот самый проверочный запрос на свежесть, который исправление табло только что добавило в каждый запрос табло. В октябре видимая часть сети стала ещё одним файлом в KV, и теперь поиск, ближайшая остановка и карта читают его на чистом JavaScript. Старый SQL остался в тестах — как ответ, с которым новый код обязан совпасть строка в строку.",[709,943,944,947,948,950],{},[712,945,946],{},"И дашборд, чтобы за этим следить."," Наша Grafana теперь вживую читает из аналитического API Cloudflare те же цифры, что стоят за страницей D1: сколько строк читает каждый запрос — за час и за одно выполнение. Последняя цифра не зависит от трафика и двигается, только когда запрос становится лучше или хуже. Заодно дашборд объяснил, почему запрос ближайшей остановки почти не попадал в рейтинг. Координаты вставлялись прямо в текст SQL — тем самым шаблонным трюком, которым я выше так гордился, — так что каждая точка считалась отдельным запросом. А его первая же неделя наглядно показывает исправление табло:",[880,949],{},[883,951],{"alt":952,"src":953},"Сколько строк в день читалось из нашей базы D1, на новом дашборде в Grafana: от 1,1 до 1,75 миллиарда в день, пока в ночь на среду, 30 сентября, не вышло исправление табло, и крохи после","\u002Fimages\u002Fcuando_routing_sql\u002Fd1-rows-per-day.png",[955,956,957],"style",{},"html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .sYf5A, html code.shiki .sYf5A{--shiki-default:#66D9EF}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html pre.shiki code .snpHw, html code.shiki .snpHw{--shiki-default:#88846F}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);}",{"title":44,"searchDepth":76,"depth":76,"links":959},[960,961,962,963,964],{"id":25,"depth":76,"text":26},{"id":168,"depth":76,"text":169},{"id":759,"depth":76,"text":760},{"id":799,"depth":76,"text":800},{"id":827,"depth":76,"text":828},"2025-12-04","Целый год поиск пути в Cuando был одним рекурсивным SQL-запросом. Этой осенью его начало вызывать приложение, и тут ему перестало хватать памяти. Как устроен поиск пути на SQL, почему он взрывается и почему с сегодняшнего дня он переехал в Worker.",false,"md","\u002Fimages\u002Fcuando_routing_sql\u002Fcover.jpg",true,{},"4","\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_routing_sql",{"title":5,"description":966},"cuando","ru\u002Fposts\u002Fcode_n_shit\u002Fcuando_routing_sql",[34,975,978,979],"sqlite","cloudflare","Wr5JKenqRnAq66h96YmpMah7w6e601oPUmim0KF6sQo",[982],{"locale":983,"path":984,"title":985},"en","\u002Fposts\u002Fcode_n_shit\u002Fcuando_routing_sql","The query that ate the database",{"slug":975,"title":987,"path":988,"chapters":989},"Как мы делали Cuando","\u002Fru\u002Fseries\u002Fcuando",[990,997,1004,1011,1012],{"path":991,"title":992,"description":993,"date":994,"image":995,"draft":967,"series":975,"part":996,"n":996},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app","Мы сделали приложение про автобусы Торревьехи. Оно задаёт один вопрос","Cuando — приложение про автобусы Торревьехи, теперь в Google Play и App Store. Данных для старта не было, так что сеть мы нарисовали руками: 163 остановки, 16 маршрутов, приложение-секундомер и куча YAML. Рассказываю, как оно работает и почему графовая база продержалась восемь недель.","2025-01-24","\u002Fimages\u002Fcuando_bus_app\u002Fcover.jpg","1",{"path":998,"title":999,"description":1000,"date":1001,"image":1002,"draft":967,"series":975,"part":1003,"n":1003},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_tiles","Платим за каждый тайл","Все решения про карту в Cuando до сих пор вытекали из того, как за эту карту брали деньги. Полгода Mapbox, одна неделя векторных тайлов, тепловая карта, которая решает, насколько глубоко можно зумить, и — с этой недели — карта, которую мы рисуем сами.","2025-02-22","\u002Fimages\u002Fcuando_tiles\u002Fcover.jpg","2",{"path":1005,"title":1006,"description":1007,"date":1008,"image":1009,"draft":967,"series":975,"part":1010,"n":1010},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_vector","Пятьдесят восемь мегабайт","Полтора года мы рисовали карту картинками. Почему Cuando переезжает на вектор: один файл на 58,67 МБ вместо 15 ГБ на тему, упавший рендерер во Flutter и три картографических движка за одной вьюхой в новых приложениях.","2026-08-15","\u002Fimages\u002Fcuando_vector\u002Fcover.jpg","3",{"path":973,"title":5,"description":966,"date":965,"image":969,"draft":967,"series":975,"part":972,"n":972},{"path":1013,"title":1014,"description":1015,"date":1016,"image":1017,"draft":967,"series":975,"part":1018,"n":1018},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_valhalla","Все лампочки горели зелёным","С сегодняшнего дня автобусные маршруты в Cuando строит Valhalla по GTFS-расписанию, которое мы генерируем сами. Мы публикуем его — или пытаемся — каждый будний день с марта, и до сегодняшнего дня никто не спросил у него ни одного автобуса. Как сделать GTFS, когда его никто не публикует, и как Valhalla теряет все автобусы, пока все проверки горят зелёным.","2026-08-04","\u002Fimages\u002Fcuando_valhalla\u002Fcover.jpg","5",1790951508423]