自控聊天:5次失败 医生不警告你

2026年8月20日1 次浏览来源:Dev.to阅读原文

正文保留英文原文(机翻易破坏代码与排版),标题/摘要已提供中文

I run self-hosted Chatwoot as the WhatsApp inbox for a dozen or so small Israeli businesses.

Two servers, a few thousand conversations a week, a drip-sequence engine bolted on the side.

Chatwoot is good software.

The self-hosting docs will get you to a running container.

What they will not tell you is which failures actually happen at month six, when you have real customers and real volume.

These five all bit me in production, and none of them looked like what they were.

1.

Your disk fills from somewhere Postgres never sees I got a disk alert at 86 percent and immediately went looking at the database.

That was the wrong place.

Attachments live in ActiveStorage, on a Docker volume, not in Postgres.

Every image, voice note, and PDF a customer sends is a file on disk, and none of it shows up when you check database size.

If your monitoring watches the DB, it will report everything is fine right up until the container cannot write.

The growth curve is a function of how many accounts you host, not how busy any one of them is.

Mine sat at roughly 0.05 GB a month until I onboarded seven new businesses over two months, and then it hit 16 GB a month.

Check the right volume:

2.

Forty-four percent of my outbound storage was duplicate files This is the part that surprised me.

When I actually measured what was on that volume, almost half the outbound media was byte-identical copies of the same file.

One 14.5 MB video was stored 48 separate times.

One image was stored 325 times.

Chatwoot creates a new blob and a new file on disk on every send, even when the bytes are identical.

That is correct behavior for a chat app where every message owns its attachment.

It becomes expensive the moment you have anything that fans one file out to many conversations.

In my case it was not campaigns at all, it was the drip engine sending the same media to 48 separate conversations as ordinary outbound messages, each one a fresh plus .

Deduplicating is safe, and I verified this in the Rails source before touching anything. is guarded by a foreign key on , so deleting one message will not take out a file that other messages still point at: One pass over existing blobs, matching on and within the same account, reclaimed 3.20 GB across 7,160 blobs and took the disk from 86 percent to 71 percent.

You want an index on before you try this, and a size floor so you are not doing lookups for every 4 KB thumbnail.

  1. returns 200 before anything has been sent This one cost me an outage, and it is entirely my own fault for reading the status code as confirmation. returns 200 immediately.
    All it has done is insert a row into with and no .
    The actual delivery to Meta happens later, in a Sidekiq job on the queue.
    The , which is the WhatsApp message ID, only gets written once Meta acknowledges.
    So a tight send loop looks completely healthy from the client side while it quietly fills a queue that everything else also depends on.
    Four resend runs kicked off within ninety seconds, roughly thirteen messages a second, pushed 5,108 messages through that endpoint.
    The queue grew to 3,786 jobs with eleven minutes of latency, and every inbound message from an actual paying customer sat behind them.
    Nothing errored.
    The dashboard just showed a lot of pending clocks, which in Chatwoot means "waiting for delivery receipt" rather than "scheduled" — a distinction I have now confused twice.
    Measure queue depth, not HTTP status: The fix that actually held was a backpressure check in my own sender: every 40 sends, count my messages still sitting at .
    Above 250, pause until it drops under 80, with a 120-second ceiling.
    That needs no access to Chatwoot's Redis and it measures the right thing, which is the pressure I created rather than global queue depth.
    4.
    Deleting an inbox is a Rails-level cascade, and it is silent During a WhatsApp Business Account migration I deleted an inbox.
    Here is what went with it: Contacts survive, because they live at the account level.
    Everything else is gone.
    This is a cascade in the Rails models, not a database constraint, so nothing in Postgres warns you and there is no confirmation dialog proportionate to what is about to happen.
    The part I did not anticipate: the real blocker afterwards was not the lost conversation history.
    It was .
    Without those rows, nothing can open a conversation at all — my engine just started returning for every contact.
    Conversation history is nice to have. is load-bearing.
    The consolation is that anything you keep in your own schema survives, since it has no foreign keys into Chatwoot's tables.
    My sequence enrollments came through untouched, so nobody's position in a drip sequence was lost.
    5.
    Restoring from backup has three traps that all look like data loss I had a backup.
    Restoring it still took most of a day, because of three things that each make it look like the restore failed when it has not.
    A trigger overwrites your . calls on a per-account sequence, BEFORE INSERT, so every conversation you inject with an explicit silently gets a brand new one.
    Every foreign reference you were trying to preserve detaches.
    The way through is to insert, then from a staging table (UPDATE does not fire that trigger), then the sequence to the real maximum.
    There are unique indexes that are not constraints.
    They do not appear in , so if you go looking for what you might collide with, you will not find them: sails straight into all three.
    Use with no target.
    One skipped row 500s the entire UI.
    A pointing at a you did not inject produces , which surfaces as an infinite spinner across the whole dashboard rather than a broken single conversation.
    Remap every orphaned before you declare the restore done.
    Worth knowing: is just the phone number in E.164 without the leading (so becomes ).
    It does not depend on which WABA you are on, which means you can rebuild these rows from even with no backup at all.
    Bonus: "timeout exceeded when trying to connect" is not your database The dashboard stopped loading with .
    The same error appeared on my background ticks, which meant sending had stopped for every client.
    It looked exactly like Postgres falling over.
    Postgres was fine.
    The cause was one inside one : With the in there, Postgres can only hash on .
    The rest becomes a Join Filter evaluated across every pair in the bucket.
    On 23K messages against 23K ledger rows that is and 125 seconds, to return zero rows.
    The cost is quadratic in campaign size, so it only detonates for your largest customers.
    The connection pool did the rest.
    It is and shared across every client's API requests plus the background ticks.
    Four of these queries at once starved it, and everything else died on a 10-second connection timeout.
    A slow query in one tenant took down every tenant.
    The fix is De Morgan, — split into two , each with a complete equality condition to hash on: 125,366 ms to 381 ms.
    I verified equivalence with a bidirectional against production across every account before shipping it, including the one account that actually had legacy rows the filter was there to catch.
    The pattern Four of these five presented as something other than what they were.
    A disk alert that was not the database.
    A 200 that had not sent.
    A timeout that was not the database either.
    A restore that looked like it had lost data it had not.
    Self-hosting Chatwoot is genuinely worth it at this scale, and I would make the same call again.
    But budget your operational attention for the layer between the container and your own code, because that is where all of this lives.
    Most of what I have learned here came out of running the WhatsApp automation I build for Israeli businesses on top of it, which is to say it came out of breaking things in front of paying customers.
    One I have not solved: has anyone found a clean way to get Chatwoot to tell you a message actually reached Meta, without polling yourself?
    I would rather subscribe to something than poll a column, and I have not found the hook.
分享