[{"data":1,"prerenderedAt":1297},["ShallowReactive",2],{"blog-post-storing-a-reading-a-minute-from-100k-devices":3,"blog-posts-pl":1230},{"original":4,"pl":689},{"id":5,"title":6,"author":7,"body":8,"date":673,"description":674,"extension":675,"lang":676,"meta":677,"navigation":204,"path":678,"readingTime":679,"seo":680,"sitemap":681,"stem":682,"tags":683,"updated":687,"__hash__":688},"blog/blog/storing-a-reading-a-minute-from-100k-devices.md","Storing a reading a minute from 100,000 devices","Bartosz Szczepanik",{"type":9,"value":10,"toc":663},"minimark",[11,15,20,23,26,30,33,36,40,43,46,49,53,61,64,68,71,74,78,81,84,346,349,352,414,418,421,424,643,646,649,653,656,659],[12,13,14],"p",{},"One reading per device per minute sounds modest. It is the kind of requirement that gets written into a spec without anyone doing the multiplication, and then it quietly decides the architecture of everything downstream. I have spent a good part of the last few years around fleets in the tens of thousands, and the problems at this scale tend to come down to arithmetic, timing and bookkeeping, done carefully.",[16,17,19],"h2",{"id":18},"start-with-the-arithmetic","Start with the arithmetic",[12,21,22],{},"A hundred thousand devices reporting every minute is about 1,667 messages per second on average. That is 144 million rows a day and a little over 52 billion rows a year. None of those numbers is frightening for a modern database on its own, but they compound. If a single reading carries a device id, a timestamp and four or five small sensor values, a row in Postgres with its header and a primary key index lands somewhere around a hundred bytes. Multiply that out and you get roughly 14 GB a day, or around 5 TB for a year of raw history, before replicas and backups.",[12,24,25],{},"The average is also the least interesting number here. What matters for sizing is the peak, and the peak is decided by something much less obvious than the fleet size.",[16,27,29],{"id":28},"the-top-of-the-minute","The top of the minute",[12,31,32],{},"Devices love round numbers. If the firmware wakes up on a timer aligned to the wall clock, a large share of your fleet will try to connect and publish in the same second, every minute, forever. Your average of 1,667 messages per second becomes a burst of tens of thousands followed by fifty-odd seconds of near silence. Brokers, load balancers and database connection pools all get sized for the burst, and you pay for that capacity during the silence too.",[12,34,35],{},"The fix costs almost nothing if you do it early. Give each device a fixed offset inside the minute, derived from something stable like a hash of its serial number, and keep sampling on the minute while spreading the publish across the whole interval. The reading still carries the timestamp of when it was measured, so nothing downstream changes, and the load curve flattens out into something you can plan around. Doing this after deployment means an OTA rollout to every device, which is exactly the kind of change you would rather not make under pressure.",[16,37,39],{"id":38},"getting-messages-off-the-broker","Getting messages off the broker",[12,41,42],{},"Most fleets like this talk MQTT, whether that is a managed service such as AWS IoT Core or a broker you run yourself like EMQX or HiveMQ. The broker is very good at holding a hundred thousand long-lived connections and fanning messages out. It is a poor place to do anything slow, so the first job of the backend is to get messages out of the broker and into something durable as quickly as possible.",[12,44,45],{},"I like putting a log in between, usually Kafka or Kinesis depending on where the rest of the system lives. The consumers that write to the database can then fall behind, restart, or be redeployed without the broker noticing, and you gain the ability to replay a day of traffic when you inevitably find a bug in how a field was parsed. With MQTT shared subscriptions you can run several bridge instances that split the incoming stream between them, which keeps that first hop horizontally scalable and boring.",[12,47,48],{},"Partition the log by device id. It keeps each device's readings in order within a partition, which makes later deduplication and any per-device logic much simpler to reason about.",[16,50,52],{"id":51},"batch-everything-you-write","Batch everything you write",[12,54,55,56,60],{},"The single biggest performance mistake I see is writing one row per message. At 1,667 inserts a second you will spend most of your database's time on transaction overhead and very little on actually storing data. Collect a few thousand rows in the consumer and write them in one statement, or use the database's bulk path such as ",[57,58,59],"code",{},"COPY"," in Postgres. ClickHouse is even fussier about this and really wants batches of thousands to tens of thousands of rows at a time. If your writers cannot batch on their side, its asynchronous inserts will do the buffering on the server for you.",[12,62,63],{},"A batch every second or two from each consumer is plenty. The latency you add is invisible to anyone looking at a dashboard, and the database will thank you.",[16,65,67],{"id":66},"duplicates-and-stragglers","Duplicates and stragglers",[12,69,70],{},"MQTT with QoS 1 guarantees a message arrives at least once, which in practice means sometimes twice. Devices that lose connectivity will also buffer readings and send them later, sometimes hours later, sometimes after a reboot that reset their sense of which messages were already acknowledged. If your storage treats every arrival as a new fact, your averages will drift and your billing reports, if you have them, will be wrong in ways that are very hard to spot.",[12,72,73],{},"The cleanest answer is to make the write idempotent. Use the device id and the measurement timestamp as the key and let the database ignore a row it has already seen. It costs a little on insert and saves you from ever having to write a deduplication job.",[16,75,77],{"id":76},"where-the-data-should-live","Where the data should live",[12,79,80],{},"For this shape of data I would reach first for TimescaleDB or ClickHouse. Both are good choices, and the decision usually comes down to the rest of your stack and what your team already knows. TimescaleDB is Postgres, so you keep joins against your device registry, transactions and the whole ecosystem of tooling. ClickHouse is faster for large analytical scans and very efficient on disk, at the cost of being a separate system with its own operational habits.",[12,82,83],{},"A schema that works well in TimescaleDB for this workload looks something like this.",[85,86,91],"pre",{"className":87,"code":88,"language":89,"meta":90,"style":90},"language-sql shiki shiki-themes arch-amber arch-amber-light","CREATE TABLE readings (\n  device_id  integer     NOT NULL,\n  ts         timestamptz NOT NULL,\n  temp       real,\n  humidity   real,\n  battery_mv smallint,\n  rssi       smallint,\n  PRIMARY KEY (device_id, ts)\n);\n\nSELECT create_hypertable('readings', by_range('ts', INTERVAL '1 day'));\n\nALTER TABLE readings SET (\n  timescaledb.compress,\n  timescaledb.compress_segmentby = 'device_id',\n  timescaledb.compress_orderby   = 'ts DESC'\n);\n\nSELECT add_compression_policy('readings', INTERVAL '2 days');\n","sql","",[57,92,93,113,128,142,153,163,174,184,193,199,206,248,253,269,275,295,311,316,321],{"__ignoreMap":90},[94,95,98,102,105,109],"span",{"class":96,"line":97},"line",1,[94,99,101],{"class":100},"s-Rrv","CREATE",[94,103,104],{"class":100}," TABLE",[94,106,108],{"class":107},"swbo2"," readings",[94,110,112],{"class":111},"sile1"," (\n",[94,114,116,119,122,125],{"class":96,"line":115},2,[94,117,118],{"class":111},"  device_id  ",[94,120,121],{"class":100},"integer",[94,123,124],{"class":100},"     NOT NULL",[94,126,127],{"class":111},",\n",[94,129,131,134,137,140],{"class":96,"line":130},3,[94,132,133],{"class":111},"  ts         ",[94,135,136],{"class":100},"timestamptz",[94,138,139],{"class":100}," NOT NULL",[94,141,127],{"class":111},[94,143,145,148,151],{"class":96,"line":144},4,[94,146,147],{"class":111},"  temp       ",[94,149,150],{"class":100},"real",[94,152,127],{"class":111},[94,154,156,159,161],{"class":96,"line":155},5,[94,157,158],{"class":111},"  humidity   ",[94,160,150],{"class":100},[94,162,127],{"class":111},[94,164,166,169,172],{"class":96,"line":165},6,[94,167,168],{"class":111},"  battery_mv ",[94,170,171],{"class":100},"smallint",[94,173,127],{"class":111},[94,175,177,180,182],{"class":96,"line":176},7,[94,178,179],{"class":111},"  rssi       ",[94,181,171],{"class":100},[94,183,127],{"class":111},[94,185,187,190],{"class":96,"line":186},8,[94,188,189],{"class":100},"  PRIMARY KEY",[94,191,192],{"class":111}," (device_id, ts)\n",[94,194,196],{"class":96,"line":195},9,[94,197,198],{"class":111},");\n",[94,200,202],{"class":96,"line":201},10,[94,203,205],{"emptyLinePlaceholder":204},true,"\n",[94,207,209,212,215,219,223,225,228,230,233,235,238,240,243,245],{"class":96,"line":208},11,[94,210,211],{"class":100},"SELECT",[94,213,214],{"class":111}," create_hypertable(",[94,216,218],{"class":217},"sfVkF","'",[94,220,222],{"class":221},"sP85D","readings",[94,224,218],{"class":217},[94,226,227],{"class":111},", by_range(",[94,229,218],{"class":217},[94,231,232],{"class":221},"ts",[94,234,218],{"class":217},[94,236,237],{"class":111},", INTERVAL ",[94,239,218],{"class":217},[94,241,242],{"class":221},"1 day",[94,244,218],{"class":217},[94,246,247],{"class":111},"));\n",[94,249,251],{"class":96,"line":250},12,[94,252,205],{"emptyLinePlaceholder":204},[94,254,256,259,261,264,267],{"class":96,"line":255},13,[94,257,258],{"class":100},"ALTER",[94,260,104],{"class":100},[94,262,263],{"class":111}," readings ",[94,265,266],{"class":100},"SET",[94,268,112],{"class":111},[94,270,272],{"class":96,"line":271},14,[94,273,274],{"class":111},"  timescaledb.compress,\n",[94,276,278,281,285,288,291,293],{"class":96,"line":277},15,[94,279,280],{"class":111},"  timescaledb.compress_segmentby ",[94,282,284],{"class":283},"sxJ4f","=",[94,286,287],{"class":217}," '",[94,289,290],{"class":221},"device_id",[94,292,218],{"class":217},[94,294,127],{"class":111},[94,296,298,301,303,305,308],{"class":96,"line":297},16,[94,299,300],{"class":111},"  timescaledb.compress_orderby   ",[94,302,284],{"class":283},[94,304,287],{"class":217},[94,306,307],{"class":221},"ts DESC",[94,309,310],{"class":217},"'\n",[94,312,314],{"class":96,"line":313},17,[94,315,198],{"class":111},[94,317,319],{"class":96,"line":318},18,[94,320,205],{"emptyLinePlaceholder":204},[94,322,324,326,329,331,333,335,337,339,342,344],{"class":96,"line":323},19,[94,325,211],{"class":100},[94,327,328],{"class":111}," add_compression_policy(",[94,330,218],{"class":217},[94,332,222],{"class":221},[94,334,218],{"class":217},[94,336,237],{"class":111},[94,338,218],{"class":217},[94,340,341],{"class":221},"2 days",[94,343,218],{"class":217},[94,345,198],{"class":111},[12,347,348],{},"Daily chunks keep each partition to around 144 million rows, which is easy to compress and cheap to drop. Segmenting the compressed data by device and ordering it by time puts each device's readings next to each other, and sensor values that change slowly compress extremely well that way. Savings of 90 percent or more are normal here, and sensor telemetry is about as friendly as data gets for compression. On the kind of data described above it is realistic to bring that 5 TB year down to a few hundred gigabytes.",[12,350,351],{},"Writes then become a batched insert that quietly skips anything it has already stored.",[85,353,355],{"className":87,"code":354,"language":89,"meta":90,"style":90},"INSERT INTO readings (device_id, ts, temp, humidity, battery_mv, rssi)\nVALUES ($1, $2, $3, $4, $5, $6), ...\nON CONFLICT (device_id, ts) DO NOTHING;\n",[57,356,357,365,406],{"__ignoreMap":90},[94,358,359,362],{"class":96,"line":97},[94,360,361],{"class":100},"INSERT INTO",[94,363,364],{"class":111}," readings (device_id, ts, temp, humidity, battery_mv, rssi)\n",[94,366,367,370,373,377,380,383,385,388,390,393,395,398,400,403],{"class":96,"line":115},[94,368,369],{"class":100},"VALUES",[94,371,372],{"class":111}," ($",[94,374,376],{"class":375},"smnN6","1",[94,378,379],{"class":111},", $",[94,381,382],{"class":375},"2",[94,384,379],{"class":111},[94,386,387],{"class":375},"3",[94,389,379],{"class":111},[94,391,392],{"class":375},"4",[94,394,379],{"class":111},[94,396,397],{"class":375},"5",[94,399,379],{"class":111},[94,401,402],{"class":375},"6",[94,404,405],{"class":111},"), ...\n",[94,407,408,411],{"class":96,"line":130},[94,409,410],{"class":100},"ON",[94,412,413],{"class":111}," CONFLICT (device_id, ts) DO NOTHING;\n",[16,415,417],{"id":416},"nobody-reads-minute-data-from-last-march","Nobody reads minute data from last March",[12,419,420],{},"Once the data is flowing, the question that keeps costs down is how long anyone actually needs each level of detail. In my experience the answer is usually short for raw readings and long for summaries. Engineers debugging a device want per-minute data from the last few days or weeks. Product dashboards and yearly reports want hourly or daily aggregates and would be slower and more expensive if they had to scan raw rows.",[12,422,423],{},"Continuous aggregates in TimescaleDB, or materialized views in ClickHouse, let you keep those summaries up to date as data arrives.",[85,425,427],{"className":87,"code":426,"language":89,"meta":90,"style":90},"CREATE MATERIALIZED VIEW readings_hourly\nWITH (timescaledb.continuous) AS\nSELECT device_id,\n       time_bucket(INTERVAL '1 hour', ts) AS bucket,\n       avg(temp)       AS temp_avg,\n       min(temp)       AS temp_min,\n       max(temp)       AS temp_max,\n       min(battery_mv) AS battery_min\nFROM readings\nGROUP BY device_id, bucket;\n\nSELECT add_continuous_aggregate_policy('readings_hourly',\n  start_offset      => INTERVAL '3 hours',\n  end_offset        => INTERVAL '1 hour',\n  schedule_interval => INTERVAL '30 minutes');\n\nSELECT add_retention_policy('readings', INTERVAL '90 days');\n",[57,428,429,436,447,454,475,488,500,512,524,532,540,544,560,580,597,615,619],{"__ignoreMap":90},[94,430,431,433],{"class":96,"line":97},[94,432,101],{"class":100},[94,434,435],{"class":111}," MATERIALIZED VIEW readings_hourly\n",[94,437,438,441,444],{"class":96,"line":115},[94,439,440],{"class":100},"WITH",[94,442,443],{"class":111}," (timescaledb.continuous) ",[94,445,446],{"class":100},"AS\n",[94,448,449,451],{"class":96,"line":130},[94,450,211],{"class":100},[94,452,453],{"class":111}," device_id,\n",[94,455,456,459,461,464,466,469,472],{"class":96,"line":144},[94,457,458],{"class":111},"       time_bucket(INTERVAL ",[94,460,218],{"class":217},[94,462,463],{"class":221},"1 hour",[94,465,218],{"class":217},[94,467,468],{"class":111},", ts) ",[94,470,471],{"class":100},"AS",[94,473,474],{"class":111}," bucket,\n",[94,476,477,480,483,485],{"class":96,"line":155},[94,478,479],{"class":107},"       avg",[94,481,482],{"class":111},"(temp)       ",[94,484,471],{"class":100},[94,486,487],{"class":111}," temp_avg,\n",[94,489,490,493,495,497],{"class":96,"line":165},[94,491,492],{"class":107},"       min",[94,494,482],{"class":111},[94,496,471],{"class":100},[94,498,499],{"class":111}," temp_min,\n",[94,501,502,505,507,509],{"class":96,"line":176},[94,503,504],{"class":107},"       max",[94,506,482],{"class":111},[94,508,471],{"class":100},[94,510,511],{"class":111}," temp_max,\n",[94,513,514,516,519,521],{"class":96,"line":186},[94,515,492],{"class":107},[94,517,518],{"class":111},"(battery_mv) ",[94,520,471],{"class":100},[94,522,523],{"class":111}," battery_min\n",[94,525,526,529],{"class":96,"line":195},[94,527,528],{"class":100},"FROM",[94,530,531],{"class":111}," readings\n",[94,533,534,537],{"class":96,"line":201},[94,535,536],{"class":100},"GROUP BY",[94,538,539],{"class":111}," device_id, bucket;\n",[94,541,542],{"class":96,"line":208},[94,543,205],{"emptyLinePlaceholder":204},[94,545,546,548,551,553,556,558],{"class":96,"line":250},[94,547,211],{"class":100},[94,549,550],{"class":111}," add_continuous_aggregate_policy(",[94,552,218],{"class":217},[94,554,555],{"class":221},"readings_hourly",[94,557,218],{"class":217},[94,559,127],{"class":111},[94,561,562,565,568,571,573,576,578],{"class":96,"line":255},[94,563,564],{"class":111},"  start_offset      ",[94,566,567],{"class":283},"=>",[94,569,570],{"class":111}," INTERVAL ",[94,572,218],{"class":217},[94,574,575],{"class":221},"3 hours",[94,577,218],{"class":217},[94,579,127],{"class":111},[94,581,582,585,587,589,591,593,595],{"class":96,"line":271},[94,583,584],{"class":111},"  end_offset        ",[94,586,567],{"class":283},[94,588,570],{"class":111},[94,590,218],{"class":217},[94,592,463],{"class":221},[94,594,218],{"class":217},[94,596,127],{"class":111},[94,598,599,602,604,606,608,611,613],{"class":96,"line":277},[94,600,601],{"class":111},"  schedule_interval ",[94,603,567],{"class":283},[94,605,570],{"class":111},[94,607,218],{"class":217},[94,609,610],{"class":221},"30 minutes",[94,612,218],{"class":217},[94,614,198],{"class":111},[94,616,617],{"class":96,"line":297},[94,618,205],{"emptyLinePlaceholder":204},[94,620,621,623,626,628,630,632,634,636,639,641],{"class":96,"line":313},[94,622,211],{"class":100},[94,624,625],{"class":111}," add_retention_policy(",[94,627,218],{"class":217},[94,629,222],{"class":221},[94,631,218],{"class":217},[94,633,237],{"class":111},[94,635,218],{"class":217},[94,637,638],{"class":221},"90 days",[94,640,218],{"class":217},[94,642,198],{"class":111},[12,644,645],{},"With that in place the raw table only ever holds three months, the hourly view holds as much history as you like at a fraction of the size, and the start offset of a few hours gives late readings a chance to land before their hour is finalised.",[12,647,648],{},"If anyone might one day need the raw history, for a warranty dispute or to train a model, export each day to Parquet on object storage before the retention policy drops it. Storage there is cheap, and tools like Athena or DuckDB can query it directly when the rare request comes in.",[16,650,652],{"id":651},"what-it-adds-up-to","What it adds up to",[12,654,655],{},"Put together, the system is not complicated. Devices spread their publishes across the minute, a broker holds the connections, a log absorbs the stream, a small group of consumers writes batches idempotently into a time series database that compresses aggressively and rolls data up, and anything old enough to be boring goes to object storage. Every piece is well understood.",[12,657,658],{},"The part that takes real effort is deciding those numbers deliberately, before the fleet exists, while changes are still cheap. If you are planning something at this scale and want a second pair of eyes on it, I am always happy to talk.",[660,661,662],"style",{},"html pre.shiki code .s-Rrv, html code.shiki .s-Rrv{--shiki-default:#F59E0B;--shiki-default-font-weight:bold;--shiki-light:#B45309;--shiki-light-font-weight:bold}html pre.shiki code .swbo2, html code.shiki .swbo2{--shiki-default:#FBBF24;--shiki-light:#A16207}html pre.shiki code .sile1, html code.shiki .sile1{--shiki-default:#FCD34D;--shiki-light:#7C2D12}html pre.shiki code .sfVkF, html code.shiki .sfVkF{--shiki-default:#D6D3D1;--shiki-light:#57534E}html pre.shiki code .sP85D, html code.shiki .sP85D{--shiki-default:#FEF3C7;--shiki-light:#78350F}html pre.shiki code .sxJ4f, html code.shiki .sxJ4f{--shiki-default:#D6D3D1;--shiki-default-font-weight:bold;--shiki-light:#57534E;--shiki-light-font-weight:bold}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 .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html pre.shiki code .smnN6, html code.shiki .smnN6{--shiki-default:#FB923C;--shiki-light:#C2410C}",{"title":90,"searchDepth":115,"depth":115,"links":664},[665,666,667,668,669,670,671,672],{"id":18,"depth":115,"text":19},{"id":28,"depth":115,"text":29},{"id":38,"depth":115,"text":39},{"id":51,"depth":115,"text":52},{"id":66,"depth":115,"text":67},{"id":76,"depth":115,"text":77},{"id":416,"depth":115,"text":417},{"id":651,"depth":115,"text":652},"2026-09-29","What it actually takes to ingest, deduplicate and keep a year of per-minute telemetry from a fleet of a hundred thousand devices, and where the costs hide.","md","en",{},"/blog/storing-a-reading-a-minute-from-100k-devices","8 min read",{"title":6,"description":674},{"loc":678},"blog/storing-a-reading-a-minute-from-100k-devices",[684,685,686],"iot","data-engineering","timescaledb",null,"fZzlC0C7tPkiljRVqJ3NFiPX5trG-d9ei7IEkILnW1k",{"id":690,"title":691,"author":7,"body":692,"date":673,"description":1223,"extension":675,"lang":1224,"meta":1225,"navigation":204,"path":678,"readingTime":1226,"seo":1227,"stem":682,"tags":1228,"updated":687,"__hash__":1229},"blogPl/blog/storing-a-reading-a-minute-from-100k-devices.md","Jak przechowywać minutowe odczyty ze 100 000 urządzeń",{"type":9,"value":693,"toc":1213},[694,697,701,704,707,711,714,717,721,724,727,730,734,740,743,747,750,753,757,760,763,949,952,955,1001,1005,1008,1011,1195,1198,1201,1205,1208,1211],[12,695,696],{},"Jeden odczyt na minutę z każdego urządzenia brzmi skromnie. To typowy wymóg, który trafia do specyfikacji, zanim ktokolwiek przemnoży liczby, a potem po cichu przesądza o architekturze wszystkiego, co znajduje się dalej w łańcuchu. Przez sporą część ostatnich lat pracowałem przy flotach liczących dziesiątki tysięcy urządzeń i w tej skali problemy sprowadzają się zwykle do arytmetyki, timingu i księgowości, byle robionych starannie.",[16,698,700],{"id":699},"zacznij-od-arytmetyki","Zacznij od arytmetyki",[12,702,703],{},"Sto tysięcy urządzeń raportujących co minutę daje średnio około 1667 wiadomości na sekundę, czyli 144 miliony wierszy dziennie i nieco ponad 52 miliardy rocznie. Żadna z tych liczb osobno nie przeraża nowoczesnej bazy danych, ale one się kumulują. Jeśli pojedynczy odczyt zawiera identyfikator urządzenia, znacznik czasu i cztery lub pięć niewielkich wartości z czujników, wiersz w Postgresie razem z nagłówkiem i indeksem klucza głównego zajmie mniej więcej sto bajtów. Po przemnożeniu wychodzi około 14 GB dziennie, czyli w okolicach 5 TB surowej historii rocznie, a to jeszcze bez replik i backupów.",[12,705,706],{},"Średnia jest zresztą najmniej ciekawą liczbą w tym zestawieniu. Przy wymiarowaniu liczy się szczyt, a o szczycie decyduje coś dużo mniej oczywistego niż wielkość floty.",[16,708,710],{"id":709},"wszyscy-naraz-o-pełnej-minucie","Wszyscy naraz o pełnej minucie",[12,712,713],{},"Urządzenia uwielbiają okrągłe liczby. Jeśli firmware budzi się na timerze wyrównanym do zegara ściennego, duża część floty będzie próbowała połączyć się i wysłać dane w tej samej sekundzie, co minutę, bez końca. Średnie 1667 wiadomości na sekundę zamienia się wtedy w skok rzędu dziesiątek tysięcy, po którym zapada pięćdziesiąt kilka sekund niemal zupełnej ciszy. Brokery, load balancery i pule połączeń do bazy wymiaruje się pod ten skok, a za tę pojemność płacisz także wtedy, gdy panuje cisza.",[12,715,716],{},"Poprawka nie kosztuje prawie nic, jeśli zrobisz ją wcześnie. Każde urządzenie dostaje stałe przesunięcie w obrębie minuty, wyliczone z czegoś stabilnego, na przykład z hasha numeru seryjnego. Próbkuje nadal o pełnej minucie, ale wysyła dane w swoim momencie, więc publikacje rozkładają się na cały interwał. Odczyt dalej niesie znacznik czasu pomiaru, dlatego w reszcie systemu nic się nie zmienia, a krzywa obciążenia wypłaszcza się do postaci, pod którą da się planować. Zrobienie tego po wdrożeniu oznacza rollout OTA na wszystkie urządzenia, a to dokładnie ten rodzaj zmiany, którego wolisz nie robić pod presją.",[16,718,720],{"id":719},"odbieranie-wiadomości-z-brokera","Odbieranie wiadomości z brokera",[12,722,723],{},"Większość takich flot mówi po MQTT: z usługą zarządzaną w rodzaju AWS IoT Core albo z brokerem utrzymywanym we własnym zakresie, jak EMQX czy HiveMQ. Broker świetnie radzi sobie z utrzymywaniem stu tysięcy długo żyjących połączeń i rozsyłaniem wiadomości. Nie jest to jednak dobre miejsce na cokolwiek powolnego, więc pierwsze zadanie backendu to jak najszybciej odebrać od niego wiadomości i przenieść je w trwałe miejsce.",[12,725,726],{},"Po drodze lubię wstawić log, zwykle Kafkę albo Kinesis, zależnie od tego, gdzie działa reszta systemu. Konsumenci zapisujący do bazy mogą wtedy zostawać w tyle, restartować się albo przechodzić ponowne wdrożenie, a broker nawet tego nie zauważy. Do tego możesz odtworzyć ruch z całego dnia, kiedy nieuchronnie trafisz na błąd w parsowaniu któregoś pola. Dzięki shared subscriptions w MQTT można uruchomić kilka instancji mostka, które dzielą między siebie przychodzący strumień, więc ten pierwszy etap skaluje się poziomo i jest po prostu nudny.",[12,728,729],{},"Log partycjonuj po identyfikatorze urządzenia. Odczyty każdego urządzenia zachowują wtedy kolejność w obrębie partycji, a to bardzo upraszcza późniejszą deduplikację i wszelką logikę dotyczącą pojedynczych urządzeń.",[16,731,733],{"id":732},"zapisuj-wszystko-batchami","Zapisuj wszystko batchami",[12,735,736,737,739],{},"Największy błąd wydajnościowy, jaki widuję, to zapisywanie osobnego wiersza dla każdej wiadomości. Przy 1667 insertach na sekundę baza spędzi większość czasu na narzucie transakcji, a bardzo mało na samym przechowywaniu danych. Zbierz w konsumencie kilka tysięcy wierszy i zapisz je jednym poleceniem albo skorzystaj z mechanizmu zapisu masowego, który oferuje baza, na przykład z ",[57,738,59],{}," w Postgresie. ClickHouse jest pod tym względem jeszcze bardziej wymagający i naprawdę chce dostawać naraz batche liczące od tysięcy do dziesiątek tysięcy wierszy. Jeśli procesy zapisujące nie potrafią batchować po swojej stronie, buforowaniem po stronie serwera zajmą się asynchroniczne inserty ClickHouse'a.",[12,741,742],{},"Batch co sekundę czy dwie z każdego konsumenta w zupełności wystarczy. Dodatkowego opóźnienia nie zauważy nikt, kto patrzy na dashboard, a baza ci podziękuje.",[16,744,746],{"id":745},"duplikaty-i-spóźnialskie-odczyty","Duplikaty i spóźnialskie odczyty",[12,748,749],{},"MQTT z QoS 1 gwarantuje dostarczenie wiadomości co najmniej raz, co w praktyce oznacza, że czasem dwa razy. Urządzenia, które tracą łączność, buforują poza tym odczyty i wysyłają je później: czasem po kilku godzinach, czasem po restarcie, po którym nie wiedzą już, które wiadomości zostały potwierdzone. Jeśli storage traktuje każdą przychodzącą wiadomość jako nowy fakt, średnie zaczną dryfować, a raporty rozliczeniowe, o ile je masz, będą błędne w sposób bardzo trudny do wychwycenia.",[12,751,752],{},"Najczystsze rozwiązanie to idempotentny zapis. Użyj identyfikatora urządzenia i znacznika czasu pomiaru jako klucza i pozwól bazie ignorować wiersze, które już widziała. Insert kosztuje przez to trochę więcej, za to nigdy nie musisz pisać joba deduplikującego.",[16,754,756],{"id":755},"gdzie-trzymać-dane","Gdzie trzymać dane",[12,758,759],{},"Przy danych o takim charakterze w pierwszej kolejności sięgnąłbym po TimescaleDB albo ClickHouse. Oba wybory są dobre, a decyzja zwykle zależy od reszty stosu i od tego, co zespół już zna. TimescaleDB to Postgres, więc zachowujesz joiny z rejestrem urządzeń, transakcje i cały ekosystem narzędzi. ClickHouse jest szybszy przy dużych skanach analitycznych i bardzo oszczędnie obchodzi się z dyskiem, ale to osobny system z własną specyfiką operacyjną.",[12,761,762],{},"Schemat, który dobrze sprawdza się w TimescaleDB przy takim obciążeniu, wygląda mniej więcej tak:",[85,764,765],{"className":87,"code":88,"language":89,"meta":90,"style":90},[57,766,767,777,787,797,805,813,821,829,835,839,843,873,877,889,893,907,919,923,927],{"__ignoreMap":90},[94,768,769,771,773,775],{"class":96,"line":97},[94,770,101],{"class":100},[94,772,104],{"class":100},[94,774,108],{"class":107},[94,776,112],{"class":111},[94,778,779,781,783,785],{"class":96,"line":115},[94,780,118],{"class":111},[94,782,121],{"class":100},[94,784,124],{"class":100},[94,786,127],{"class":111},[94,788,789,791,793,795],{"class":96,"line":130},[94,790,133],{"class":111},[94,792,136],{"class":100},[94,794,139],{"class":100},[94,796,127],{"class":111},[94,798,799,801,803],{"class":96,"line":144},[94,800,147],{"class":111},[94,802,150],{"class":100},[94,804,127],{"class":111},[94,806,807,809,811],{"class":96,"line":155},[94,808,158],{"class":111},[94,810,150],{"class":100},[94,812,127],{"class":111},[94,814,815,817,819],{"class":96,"line":165},[94,816,168],{"class":111},[94,818,171],{"class":100},[94,820,127],{"class":111},[94,822,823,825,827],{"class":96,"line":176},[94,824,179],{"class":111},[94,826,171],{"class":100},[94,828,127],{"class":111},[94,830,831,833],{"class":96,"line":186},[94,832,189],{"class":100},[94,834,192],{"class":111},[94,836,837],{"class":96,"line":195},[94,838,198],{"class":111},[94,840,841],{"class":96,"line":201},[94,842,205],{"emptyLinePlaceholder":204},[94,844,845,847,849,851,853,855,857,859,861,863,865,867,869,871],{"class":96,"line":208},[94,846,211],{"class":100},[94,848,214],{"class":111},[94,850,218],{"class":217},[94,852,222],{"class":221},[94,854,218],{"class":217},[94,856,227],{"class":111},[94,858,218],{"class":217},[94,860,232],{"class":221},[94,862,218],{"class":217},[94,864,237],{"class":111},[94,866,218],{"class":217},[94,868,242],{"class":221},[94,870,218],{"class":217},[94,872,247],{"class":111},[94,874,875],{"class":96,"line":250},[94,876,205],{"emptyLinePlaceholder":204},[94,878,879,881,883,885,887],{"class":96,"line":255},[94,880,258],{"class":100},[94,882,104],{"class":100},[94,884,263],{"class":111},[94,886,266],{"class":100},[94,888,112],{"class":111},[94,890,891],{"class":96,"line":271},[94,892,274],{"class":111},[94,894,895,897,899,901,903,905],{"class":96,"line":277},[94,896,280],{"class":111},[94,898,284],{"class":283},[94,900,287],{"class":217},[94,902,290],{"class":221},[94,904,218],{"class":217},[94,906,127],{"class":111},[94,908,909,911,913,915,917],{"class":96,"line":297},[94,910,300],{"class":111},[94,912,284],{"class":283},[94,914,287],{"class":217},[94,916,307],{"class":221},[94,918,310],{"class":217},[94,920,921],{"class":96,"line":313},[94,922,198],{"class":111},[94,924,925],{"class":96,"line":318},[94,926,205],{"emptyLinePlaceholder":204},[94,928,929,931,933,935,937,939,941,943,945,947],{"class":96,"line":323},[94,930,211],{"class":100},[94,932,328],{"class":111},[94,934,218],{"class":217},[94,936,222],{"class":221},[94,938,218],{"class":217},[94,940,237],{"class":111},[94,942,218],{"class":217},[94,944,341],{"class":221},[94,946,218],{"class":217},[94,948,198],{"class":111},[12,950,951],{},"Dzienne chunki ograniczają każdą partycję do mniej więcej 144 milionów wierszy, które łatwo skompresować i tanio usunąć. Segmentowanie skompresowanych danych po urządzeniu i sortowanie ich po czasie układa odczyty z jednego urządzenia obok siebie, a wolno zmieniające się wartości z czujników kompresują się wtedy wyjątkowo dobrze. Oszczędności rzędu 90 procent lub więcej są tu normą, a telemetria z czujników to chyba najwdzięczniejsze do kompresji dane, jakie istnieją. Przy danych opisanych wyżej można realnie zejść z 5 TB rocznie do kilkuset gigabajtów.",[12,953,954],{},"Zapis sprowadza się wtedy do batchowego insertu, który po cichu pomija wszystko, co już jest w bazie:",[85,956,957],{"className":87,"code":354,"language":89,"meta":90,"style":90},[57,958,959,965,995],{"__ignoreMap":90},[94,960,961,963],{"class":96,"line":97},[94,962,361],{"class":100},[94,964,364],{"class":111},[94,966,967,969,971,973,975,977,979,981,983,985,987,989,991,993],{"class":96,"line":115},[94,968,369],{"class":100},[94,970,372],{"class":111},[94,972,376],{"class":375},[94,974,379],{"class":111},[94,976,382],{"class":375},[94,978,379],{"class":111},[94,980,387],{"class":375},[94,982,379],{"class":111},[94,984,392],{"class":375},[94,986,379],{"class":111},[94,988,397],{"class":375},[94,990,379],{"class":111},[94,992,402],{"class":375},[94,994,405],{"class":111},[94,996,997,999],{"class":96,"line":130},[94,998,410],{"class":100},[94,1000,413],{"class":111},[16,1002,1004],{"id":1003},"nikt-nie-czyta-minutowych-danych-z-zeszłego-marca","Nikt nie czyta minutowych danych z zeszłego marca",[12,1006,1007],{},"Gdy dane już płyną, o kosztach decyduje pytanie, jak długo komukolwiek naprawdę potrzebny jest każdy poziom szczegółowości. Z mojego doświadczenia odpowiedź brzmi zwykle: krótko w przypadku surowych odczytów i długo w przypadku podsumowań. Inżynier, który debuguje urządzenie, chce danych minutowych z ostatnich kilku dni lub tygodni. Dashboardy produktowe i roczne raporty potrzebują agregatów godzinowych albo dziennych, a gdyby musiały skanować surowe wiersze, działałyby wolniej i kosztowały więcej.",[12,1009,1010],{},"Continuous aggregates w TimescaleDB albo materialized views w ClickHouse pozwalają aktualizować te podsumowania na bieżąco, w miarę napływu danych.",[85,1012,1013],{"className":87,"code":426,"language":89,"meta":90,"style":90},[57,1014,1015,1021,1029,1035,1051,1061,1071,1081,1091,1097,1103,1107,1121,1137,1153,1169,1173],{"__ignoreMap":90},[94,1016,1017,1019],{"class":96,"line":97},[94,1018,101],{"class":100},[94,1020,435],{"class":111},[94,1022,1023,1025,1027],{"class":96,"line":115},[94,1024,440],{"class":100},[94,1026,443],{"class":111},[94,1028,446],{"class":100},[94,1030,1031,1033],{"class":96,"line":130},[94,1032,211],{"class":100},[94,1034,453],{"class":111},[94,1036,1037,1039,1041,1043,1045,1047,1049],{"class":96,"line":144},[94,1038,458],{"class":111},[94,1040,218],{"class":217},[94,1042,463],{"class":221},[94,1044,218],{"class":217},[94,1046,468],{"class":111},[94,1048,471],{"class":100},[94,1050,474],{"class":111},[94,1052,1053,1055,1057,1059],{"class":96,"line":155},[94,1054,479],{"class":107},[94,1056,482],{"class":111},[94,1058,471],{"class":100},[94,1060,487],{"class":111},[94,1062,1063,1065,1067,1069],{"class":96,"line":165},[94,1064,492],{"class":107},[94,1066,482],{"class":111},[94,1068,471],{"class":100},[94,1070,499],{"class":111},[94,1072,1073,1075,1077,1079],{"class":96,"line":176},[94,1074,504],{"class":107},[94,1076,482],{"class":111},[94,1078,471],{"class":100},[94,1080,511],{"class":111},[94,1082,1083,1085,1087,1089],{"class":96,"line":186},[94,1084,492],{"class":107},[94,1086,518],{"class":111},[94,1088,471],{"class":100},[94,1090,523],{"class":111},[94,1092,1093,1095],{"class":96,"line":195},[94,1094,528],{"class":100},[94,1096,531],{"class":111},[94,1098,1099,1101],{"class":96,"line":201},[94,1100,536],{"class":100},[94,1102,539],{"class":111},[94,1104,1105],{"class":96,"line":208},[94,1106,205],{"emptyLinePlaceholder":204},[94,1108,1109,1111,1113,1115,1117,1119],{"class":96,"line":250},[94,1110,211],{"class":100},[94,1112,550],{"class":111},[94,1114,218],{"class":217},[94,1116,555],{"class":221},[94,1118,218],{"class":217},[94,1120,127],{"class":111},[94,1122,1123,1125,1127,1129,1131,1133,1135],{"class":96,"line":255},[94,1124,564],{"class":111},[94,1126,567],{"class":283},[94,1128,570],{"class":111},[94,1130,218],{"class":217},[94,1132,575],{"class":221},[94,1134,218],{"class":217},[94,1136,127],{"class":111},[94,1138,1139,1141,1143,1145,1147,1149,1151],{"class":96,"line":271},[94,1140,584],{"class":111},[94,1142,567],{"class":283},[94,1144,570],{"class":111},[94,1146,218],{"class":217},[94,1148,463],{"class":221},[94,1150,218],{"class":217},[94,1152,127],{"class":111},[94,1154,1155,1157,1159,1161,1163,1165,1167],{"class":96,"line":277},[94,1156,601],{"class":111},[94,1158,567],{"class":283},[94,1160,570],{"class":111},[94,1162,218],{"class":217},[94,1164,610],{"class":221},[94,1166,218],{"class":217},[94,1168,198],{"class":111},[94,1170,1171],{"class":96,"line":297},[94,1172,205],{"emptyLinePlaceholder":204},[94,1174,1175,1177,1179,1181,1183,1185,1187,1189,1191,1193],{"class":96,"line":313},[94,1176,211],{"class":100},[94,1178,625],{"class":111},[94,1180,218],{"class":217},[94,1182,222],{"class":221},[94,1184,218],{"class":217},[94,1186,237],{"class":111},[94,1188,218],{"class":217},[94,1190,638],{"class":221},[94,1192,218],{"class":217},[94,1194,198],{"class":111},[12,1196,1197],{},"Przy takiej konfiguracji surowa tabela przechowuje zawsze tylko ostatnie trzy miesiące, widok godzinowy trzyma dowolnie długą historię za ułamek miejsca, a start offset rzędu kilku godzin daje spóźnionym odczytom szansę dotrzeć, zanim ich godzina zostanie domknięta.",[12,1199,1200],{},"Jeśli surowa historia może się kiedyś komuś przydać, na przykład przy sporze gwarancyjnym albo do trenowania modelu, eksportuj każdy dzień do Parquetu na object storage, zanim polityka retencji go usunie. Przechowywanie tam jest tanie, a narzędzia takie jak Athena czy DuckDB odpytują te pliki bezpośrednio, gdy raz na jakiś czas ktoś o nie poprosi.",[16,1202,1204],{"id":1203},"wszystko-razem","Wszystko razem",[12,1206,1207],{},"Cały system nie jest skomplikowany. Urządzenia rozkładają publikacje na całą minutę, broker utrzymuje połączenia, log przyjmuje strumień, niewielka grupa konsumentów idempotentnie zapisuje batche do bazy szeregów czasowych, która agresywnie kompresuje i agreguje dane, a wszystko, co jest już na tyle stare, że zrobiło się nudne, trafia do object storage. Każdy z tych elementów jest dobrze znany.",[12,1209,1210],{},"Prawdziwego wysiłku wymaga świadome ustalenie tych wszystkich liczb, zanim flota powstanie, póki zmiany są jeszcze tanie. Jeśli planujesz coś w tej skali i przydałoby ci się drugie spojrzenie, zawsze chętnie porozmawiam.",[660,1212,662],{},{"title":90,"searchDepth":115,"depth":115,"links":1214},[1215,1216,1217,1218,1219,1220,1221,1222],{"id":699,"depth":115,"text":700},{"id":709,"depth":115,"text":710},{"id":719,"depth":115,"text":720},{"id":732,"depth":115,"text":733},{"id":745,"depth":115,"text":746},{"id":755,"depth":115,"text":756},{"id":1003,"depth":115,"text":1004},{"id":1203,"depth":115,"text":1204},"Co naprawdę trzeba zrobić, żeby przyjąć, zdeduplikować i przez rok trzymać minutową telemetrię ze 100 000 urządzeń, i gdzie kryją się koszty.","pl",{},"8 min czytania",{"title":691,"description":1223},[684,685,686],"FUbAfhpuLs7mMv6zA44xjcFQHy9ve9Hd0TXD_pT2Rak",[1231,1241,1252,1258,1268,1277,1287,1295],{"path":1232,"stem":1233,"title":1234,"description":1235,"date":1236,"tags":1237,"readingTime":1226,"lang":1224},"/blog/what-cra-compliance-takes-for-a-small-hardware-team","blog/what-cra-compliance-takes-for-a-small-hardware-team","Czego CRA naprawdę wymaga od małej firmy sprzętowej","Przeszedłem przez Cyber Resilience Act punkt po punkcie i zebrałem obowiązki producenta w jedną checklistę. Czego wymaga od urządzenia IoT i od czego zacząć.","2026-10-04",[1238,684,1239,1240],"cra","security","compliance",{"path":1242,"stem":1243,"title":1244,"description":1245,"date":1246,"tags":1247,"readingTime":1251,"lang":1224},"/blog/one-golden-certificate-for-the-whole-production-line","blog/one-golden-certificate-for-the-whole-production-line","Jeden złoty certyfikat na całą linię produkcyjną","Jak nadać każdemu urządzeniu własną tożsamość X.509 w AWS IoT Core bez wgrywania certyfikatów sztuka po sztuce: certyfikat claim i fleet provisioning.","2026-10-01",[684,1248,1239,1249,1250],"aws","provisioning","esp32","9 min czytania",{"path":1253,"stem":1254,"title":1255,"description":1256,"date":1246,"tags":1257,"readingTime":1251,"lang":1224},"/blog/the-cra-clock-is-already-running","blog/the-cra-clock-is-already-running","Zegar CRA już tyka, także dla urządzeń sprzedanych lata temu","Od 11 września 2026 CRA wymaga zgłoszeń w ciągu 24 godzin dla każdego urządzenia IoT na rynku UE, także starego. Co to znaczy dla małej firmy sprzętowej?",[1238,684,1239,1240],{"path":1259,"stem":1260,"title":1261,"description":1262,"date":1263,"tags":1264,"readingTime":1267,"lang":1224},"/blog/updating-firmware-on-a-fleet-without-bricking-it","blog/updating-firmware-on-a-fleet-without-bricking-it","Aktualizacja 50 000 ESP32 tak, żeby żadnego nie uceglić","Aktualizacje OTA dużej floty ESP32: układ partycji i rollback, podpisane obrazy, wdrażanie etapami i ile kosztuje wysłanie megabajta na każde urządzenie.","2026-09-30",[684,1250,1265,1266],"firmware","ota","7 min czytania",{"path":1269,"stem":1270,"title":1271,"description":1272,"date":1263,"tags":1273,"readingTime":1267,"lang":1224},"/blog/where-the-battery-goes-on-an-lte-m-sensor","blog/where-the-battery-goes-on-an-lte-m-sensor","Na co czujnik LTE-M zużywa baterię","Budżet energetyczny czujnika LTE-M na baterii: dlaczego dominuje w nim radio i które ustawienia decydują, czy bateria wytrzyma rok, czy pięć lat.",[684,1274,1275,1276],"embedded","lte-m","low-power",{"path":1278,"stem":1279,"title":1280,"description":1281,"date":673,"tags":1282,"readingTime":1286,"lang":1224},"/blog/building-an-llm-trading-bot","blog/building-an-llm-trading-bot","Wszyscy nagle budują boty tradingowe na LLM-ach","Ciągle słyszałem o modelach językowych, które same handlują, więc sprawdziłem, jak działają takie boty, gdzie się sypią i co zbudowałbym na ich miejscu.",[1283,1284,1285],"ai","llm","trading","6 min czytania",{"path":1288,"stem":1289,"title":1290,"description":1291,"date":673,"tags":1292,"readingTime":1294,"lang":1224},"/blog/jev-a-model-that-only-decides","blog/jev-a-model-that-only-decides","JEV: model, który tylko decyduje","JEV od TypeSafe AI zwraca typowane odpowiedzi ze skalibrowanym prawdopodobieństwem i nie pisze ani słowa. Czym jest, gdzie się sprawdzi i na co uważać.",[1283,1284,1293],"jev","5 min czytania",{"path":678,"stem":682,"title":691,"description":1223,"date":673,"tags":1296,"readingTime":1226,"lang":1224},[684,685,686],1792141330800]