惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Troy Hunt's Blog
Blog — PlanetScale
Blog — PlanetScale
Engineering at Meta
Engineering at Meta
F
Full Disclosure
Recorded Future
Recorded Future
The GitHub Blog
The GitHub Blog
Microsoft Security Blog
Microsoft Security Blog
GbyAI
GbyAI
博客园_首页
博客园 - 叶小钗
MongoDB | Blog
MongoDB | Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Recent Commits to openclaw:main
Recent Commits to openclaw:main
H
Hacker News: Front Page
人人都是产品经理
人人都是产品经理
The Cloudflare Blog
博客园 - 司徒正美
Webroot Blog
Webroot Blog
Google DeepMind News
Google DeepMind News
Help Net Security
Help Net Security
Cloudbric
Cloudbric
PCI Perspectives
PCI Perspectives
有赞技术团队
有赞技术团队
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
TaoSecurity Blog
TaoSecurity Blog
L
Lohrmann on Cybersecurity
量子位
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Tailwind CSS Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
B
Blog RSS Feed
Apple Machine Learning Research
Apple Machine Learning Research
大猫的无限游戏
大猫的无限游戏
P
Proofpoint News Feed
N
News and Events Feed by Topic
罗磊的独立博客
T
Threat Research - Cisco Blogs
Schneier on Security
Schneier on Security
T
Tor Project blog
IT之家
IT之家
M
MIT News - Artificial intelligence
S
Security @ Cisco Blogs
O
OpenAI News
AI
AI
S
Securelist
Simon Willison's Weblog
Simon Willison's Weblog
The Last Watchdog
The Last Watchdog
月光博客
月光博客
Security Archives - TechRepublic
Security Archives - TechRepublic
L
LINUX DO - 热门话题

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Hexagons, Hypertables, and 240 Dead Tags: Migrating a Maritime Data Platform to TimescaleDB
VesselAPI · 2026-05-15 · via DEV Community

Every ship in the world is constantly shouting its name into the void. Its position, its heading, its speed, its destination — broadcast every few seconds via radio, picked up by satellites and shore stations, and funneled into databases that try to make sense of it all. At VesselAPI, we run one of those databases. And for the first year of our existence, we ran it on MongoDB. This is the story of why we stopped.

It's also a story about hexagons and a single mismatched struct tag that quietly broke an entire data pipeline.

The Shape of the Problem

AIS — the Automatic Identification System — is the backbone of maritime surveillance. Most commercial vessels are legally required to carry a transponder that broadcasts their identity and position — SOLAS mandates it for ships of 300 gross tonnage and upwards on international voyages, and many flag states extend the requirement further. The result is a firehose: at peak hours, we ingest roughly 700,000 position reports every sixty minutes. Each one is a point in space and time — latitude, longitude, timestamp, vessel identifier, plus speed, heading, and a handful of other fields. Position-in-time is the core of it.

If you squint at this data, it looks like a document. A position report has fields. You can serialize it as JSON. MongoDB will happily store it. And for the first few months, that was fine. We were building fast, the schema was changing daily, and MongoDB's flexibility was genuinely useful. Hard to have migration problems when there's nothing to migrate.

But here's the thing about vessel positions: they aren't documents. They're measurements. They have a timestamp and a location, and those two properties aren't just metadata — they're the entire point. The questions you ask of this data are fundamentally about time and space: Where was this ship two hours ago? What vessels are within 50 kilometers of Rotterdam right now? Show me everything that passed through the English Channel since Tuesday.

At the time, MongoDB had no native concept of any of this. (It has since added time-series collections, though they remain limited compared to purpose-built solutions.) It didn't understand that timestamps partition naturally into chunks, that old data expires, or that latitude and longitude define a point on a sphere where "within 50 kilometers" is a question with real mathematical structure. You can bolt on 2dsphere indexes and TTL policies, but you're fighting the grain of the database. And at 700,000 rows per hour, fighting the grain gets expensive fast.

What We Needed (and What Exists)

I wrote the requirements on a whiteboard one afternoon and stood back. Time-series ingestion at sustained throughput. Automatic partitioning by time. Compression of old data. Retention policies that don't involve cron. Spatial queries on a sphere. Full-text search. Relational joins. And ideally, something I could operate without a dedicated DBA. Looking at the list, I remember thinking: this is either one very specific database, or three separate ones duct-taped together.

I spent a week evaluating alternatives. InfluxDB handles time-series beautifully but its spatial support was experimental, living in Flux — which is now being deprecated in InfluxDB 3.0, taking the geo package with it. ClickHouse kept coming up in benchmarks but the operational overhead scared me, and PostGIS isn't an option there. MongoDB we already knew about.

TimescaleDB is PostgreSQL with a time-series engine bolted on at a level deep enough that it feels native. And because it is PostgreSQL, you get PostGIS for spatial queries, H3 for hexagonal indexing, GIN indexes for full-text search, and nearly thirty years of battle-tested relational database engineering. Turns out we didn't need three databases duct-taped together. We needed one.

We chose it. Then we had to figure out what "time-series thinking" actually means in practice.

Sounds interesting? Check out TimescaleDB

Data With a Shelf Life

The central abstraction in TimescaleDB is the hypertable. From the outside, it looks like a regular PostgreSQL table. You INSERT into it, you SELECT from it, you index it. But underneath, the data is automatically partitioned into chunks — contiguous slices of time, each stored as a separate physical table.

I didn't appreciate how much this changes until I stopped thinking about storage and started thinking about expiry.

Our vessel_positions hypertable uses 1-hour chunks. That means every hour of AIS data lives in its own self-contained partition. When we set a 78-hour retention policy, TimescaleDB doesn't scan through millions of rows looking for old records to delete — it just drops the chunks that have aged out. The entire partition disappears. It takes milliseconds.

16.5 million vessel positions, 78-hour retention, 1-hour chunks. The table is always roughly the same size, no matter how long the system runs.

Compression works the same way. After a chunk is two hours old — meaning we're no longer actively writing to it — TimescaleDB compresses it automatically. We segment the compression by MMSI (the vessel's radio identifier, broadcast in every AIS message) and order by timestamp descending. This means "give me the latest position for vessel X" can be answered from the compressed data without decompressing the entire chunk. The storage savings are substantial; the performance improvement for time-range queries is even better, because the query planner knows which chunks to skip entirely.

ALTER TABLE vessel_positions SET (
    timescaledb.compress,
    timescaledb.compress_segmentby = 'mmsi',
    timescaledb.compress_orderby = 'timestamp DESC'
);
SELECT add_compression_policy('vessel_positions', INTERVAL '2 hours');

Enter fullscreen mode Exit fullscreen mode

In MongoDB, I had a cron job that ran a cleanup script every few hours. It failed silently for a week once and nobody noticed until disk usage alerted. In TimescaleDB, we just declare the retention policy and the database handles expiry itself. One fewer thing running at 3 AM that I have to worry about.

The Hexagon Problem

Here's a question that sounds simple: find all vessels within 100 kilometers of a given point.

PostGIS can answer this. You create a GIST spatial index on your geometry column, and the function ST_DWithin will find every point within a given distance. Under the hood, it uses the spatial index to eliminate obvious non-candidates via bounding box checks, then computes exact distances for the rest. It works. It's well-engineered.

But when your table has 16 million rows and new ones arrive at 12,000 per minute, "it works" isn't quite enough. The GIST index is good, but it still has to traverse a tree structure built on geometry — bounding boxes nested inside bounding boxes. For high-volume tables with constant inserts, this gets heavy.

So we added a layer in front of it. And that layer is made of hexagons.

H3 is a spatial indexing system originally developed at Uber for matching riders to drivers. It tiles the entire surface of the Earth with hexagons at multiple resolutions — coarser at low resolutions, finer at high ones. Every point on the planet falls inside exactly one hexagon at each resolution level, and each hexagon has a unique integer identifier.

We use resolution 5, where each hexagon has an edge length of roughly 9 kilometers and an area of roughly 253 square kilometers. The entire Earth is covered by about 2 million of these cells. Every vessel position, when it's inserted, gets a computed H3 cell stored alongside it:

h3_cell_res5 H3INDEX GENERATED ALWAYS AS (
    h3_lat_lng_to_cell(location::point, 5)
) STORED

Enter fullscreen mode Exit fullscreen mode

That STORED keyword matters. The H3 cell is computed once, at insert time, and written to disk as an integer. No recalculation needed at query time. And because it's an integer, we can slap a plain B-tree index on it — the simplest, fastest index PostgreSQL knows how to build.

Now, when someone asks for vessels within 100 kilometers of a point, the query doesn't go straight to the spatial index. First, we compute which H3 cells overlap the search area — a quick geometric calculation that returns a handful of integer IDs. Then we filter the table to only rows matching those cell IDs, using the B-tree index. Integer equality. Blazing fast. This turns 16 million candidate rows into a few thousand.

Then PostGIS takes over, running ST_DWithin on the survivors for exact distance calculations. A few thousand rows through a precise spatial filter is trivial.

-- Stage 1: H3 pre-filter (integer comparison, B-tree)
h3_cell_res5 = ANY(ARRAY(
    SELECT h3_grid_disk(h3_lat_lng_to_cell(ST_MakePoint($1,$2)::point, 5), $6)
))
-- Stage 2: PostGIS exact filter (geometry, GIST)
AND ST_DWithin(location, ST_SetSRID(ST_MakePoint($1,$2), 4326)::geography, $3)
-- Stage 3: TimescaleDB chunk pruning (time range)
AND timestamp BETWEEN $4 AND $5

Enter fullscreen mode Exit fullscreen mode

Three layers of filtering, each narrowing the candidate set for the next: H3 knocks it down from millions to thousands, PostGIS from thousands to hundreds, and chunk exclusion keeps you from scanning data outside the time window entirely.

Why hexagons, specifically? Because hexagons are the only regular polygon that tiles a plane with uniform adjacency — every neighbor shares an edge, and the distance from center to center is the same in every direction. Squares have diagonal neighbors that sit further away than edge neighbors, which distorts distance calculations. For spatial proximity queries, hexagons give you the least distortion. Uber didn't invent this insight — anyone who's looked at a honeycomb has seen it — but they did build a production-grade library around it.

We tried resolution 4 first. The cells were too big — a single cell covered so much ocean that the pre-filter wasn't filtering much. Resolution 6 was better spatially but generated too many cells per query, and the B-tree had to check them all. Resolution 5 was the one where a 100-kilometer radius query overlapped a manageable number of cells while still meaningfully shrinking the candidate set. We benchmarked it and moved on.

Feeding the Beast

700,000 position reports per hour means roughly 194 inserts per second, sustained. That's not a terrifying number for PostgreSQL — a well-tuned instance can handle far more — but the naive approach still hurts. Individual INSERT statements, each sent as a separate network round trip, spend more time in protocol overhead than actual writing. The database is fast; the network between your application and the database is not.

The obvious answer is PostgreSQL's COPY protocol, which streams raw row data in binary, bypassing the SQL parser entirely. We use it for vessel_eta and cache_ais_messages. But for vessel_positions, we can't. The reason is our own schema: the h3_cell_res5 generated column uses the H3INDEX type, and H3INDEX doesn't implement PostgreSQL's binary I/O functions. The COPY protocol requires binary serialization for every column type in the target table. No binary I/O, no COPY.

So we use pgx.Batch with SendBatch instead — the extended query protocol. It packs hundreds of parameterized INSERT statements into a single network round trip, and PostgreSQL executes them server-side without per-statement overhead. Not as fast as COPY, but an order of magnitude better than individual round trips:

batch := &pgx.Batch{}
for _, p := range positions {
    batch.Queue(
        `INSERT INTO vessel_positions
         (mmsi, imo, vessel_name, latitude, longitude, location,
          timestamp, processed_timestamp, suspected_glitch, ...)
         VALUES ($1, $2, $3, $4, $5,
                 ST_SetSRID(ST_MakePoint($5, $4), 4326),
                 $6, $7, $8, ...)`,
        p.MMSI, imo, p.VesselName,
        p.Latitude, p.Longitude,
        p.Timestamp, p.ProcessedTimestamp,
        p.SuspectedGlitch, /* ... */
    )
}
results := pool.SendBatch(ctx, batch)

Enter fullscreen mode Exit fullscreen mode

Notice that the geometry is computed server-side with ST_SetSRID(ST_MakePoint(...)). I briefly considered pre-computing EWKB in the application to avoid calling a function 700,000 times per hour. But since we were already on SendBatch rather than COPY, and ST_MakePoint is cheap server-side, the optimization wasn't worth the added complexity. Sometimes the schema you designed to make reads fast makes writes slightly harder. I'd make that trade-off again.

The Character That Broke the Ports

A port at night with container cranes lit against the dark sky, overlaid with a code error showing mismatched struct field names

When your database speaks JSON but your structs still think in BSON.

Here is a bug that could only exist in a migration.

Our port data comes from two external sources. One is a scraper that crawls MyShipTracking's sitemap and extracts basic port info — name, country, coordinates, UN/LOCODE. The other is the World Port Index, maintained by the US National Geospatial-Intelligence Agency, which provides detailed harbor characteristics: depths, pilotage requirements, tug availability, dozens of operational fields.

Neither source writes directly to the production ports table. Instead, they dump raw JSON documents into staging tables — cache_port_mst and cache_port_wpi — and a consolidation step merges them. MST provides breadth (6,488 ports), WPI provides depth (~3,700 ports with rich metadata). The consolidator joins them on UN/LOCODE and writes the merged result to production. Clean separation of concerns.

After the migration went live, the MST scraper worked perfectly. 6,488 ports in the staging table. But the WPI staging table was empty. Zero rows. The consolidator, finding nothing to merge, produced nothing. The production ports table: empty. And because port events rely on the ports table for UN/LOCODE enrichment, 156,000 port events were created with no geographic identifier. Everything looked healthy from the outside. The data was garbage.

The root cause was one word.

The WPI port struct, a holdover from the MongoDB era, still carried dual serialization tags — something like:

type WpiPort struct {
    UnloCode string `bson:"unlo_code" json:"unloCode"`
    // ...
}

Enter fullscreen mode Exit fullscreen mode

The staging table upsert function works by marshaling each struct to JSON, then extracting a key field by name. The key parameter was "unlo_code" — the BSON tag name, used by MongoDB's driver. But json.Marshal uses the JSON tag: "unloCode". The function looked for a field called unlo_code in the JSON document, found nothing, and returned an error. Not a silent error, technically — the function threw a clear "key field not found" message. But without alerting on the nightly WPI sync job, a returned error that nobody checks is as good as silent. It ran, it failed, it failed again, every night at 1 AM, for days.

The fix was changing one string: "unlo_code" to "unloCode".

But the real fix was broader. I searched the codebase and found nearly 240 leftover bson:"..." tags scattered across the data contract structs. The MongoDB driver wasn't even imported anymore — these tags were pure vestigial code, left over from the old world. Every one of them was a potential version of the same bug: a name from a system that no longer existed, waiting to be confused with a name from the system that did.

I spent two days on this. Two days staring at logs, convinced the WPI API had changed its response format, before I thought to check the struct tags. It's a naming collision between two eras of the same system, and Go is happy to let it happen — struct tags are opaque strings the compiler ignores completely. No linter will save you. You have to notice it yourself, or wait for production to notice it for you.

What the Numbers Look Like

Table Type Rows Notes
vessel_positions Hypertable 16.5M ~700K/hour, H3 + GIST + B-tree indexes
vessel_eta Hypertable 78h retention, compressed
port_events Hypertable 156K Dedup index, UN/LOCODEs empty (pre-fix)
cache_ais_messages Hypertable Raw AIS buffer, 12h retention
vessels Regular ~50K Consolidated from multiple sources
ports Regular ~6,500 After WPI fix; 0 before
light_aids Regular 35,237 Navigation infrastructure
dgps_stations Regular 163 DGPS reference stations
navtex_messages Regular ~93/day Deduplicated by content hash

Production table statistics after migration

All of this runs on a single EC2 r7i.large — 2 vCPUs, 16 GB of RAM. I keep expecting to need to upgrade and I keep not needing to. PostgreSQL with the right extensions, doing the work that previously required MongoDB plus a constellation of application-level workarounds for everything MongoDB couldn't do natively.

A bulk carrier navigating a Norwegian fjord at dawn, with a faint AIS data trail arcing behind it

A bulk carrier transiting a Norwegian fjord — one of roughly 700,000 position reports we process every hour.

What I'd Tell You at a Bar

Look, MongoDB was the right call when we started. I'd choose it again for that stage. The mistake was staying six months too long — past the point where the data had obviously hardened into a shape and we were just too busy to deal with it.

Honestly, moving the data was the easy part. The hard part — the part that's still ongoing — is finding all the places where the old system's assumptions are embedded in the code. A struct tag referencing a serialization format you don't use anymore. A key parameter someone copied from a different struct's bson tag. Those things survive the migration and sit there until they don't.

We're still finding bson tags. Probably will be for a while.