2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発で5年作り続けたオンラインCSシステムで遭遇した「UTF-8 の罠」――たった一つの絵文字が履歴を消した話

Posted at

私は空き時間を使って、個人開発のプロダクト ShenDesk(オンラインカスタマーサポート&マーケティングシステム) を開発してきました。
少しずつ、数年かけて作り続け、最初は「試しに使ってみる」程度のユーザーしかいませんでしたが、今ではクラウド版・オンプレミス版の両方で、安定して利用してくれるユーザーが着実に増えています。

その過程で、「個人開発プロダクトをどう作り、どう運営していくか」について、かなりの経験を積むことができました。

正直に言うと、これまで何度もリリース後にこう思ってきました。
「このバージョンはもう、絶対に安定してるだろ 😎」

……ところが、しばらくすると必ずこうなる。
「え? そんな変な不具合、まだ出るの? 😗」

数年前までは、しょっちゅう「え?」が出ていました。
今年に入ってからは、たまに「え?」。
そして今。
ようやく本当に「老犬のように安定した」と言っていい状態になったと感じています。

というのも、ここ最近、複数の顧客環境を最新バージョンへアップグレードしましたが、すべて問題なく、静かに、安定して動いているからです。

自分自身の本番環境も同じです。
以前は、ユーザーから
「この環境でこんな問題が出た」
「この使い方だと挙動がおかしい」
といったフィードバックを頻繁にもらっていました。

しかし今では、利用者数も増え、オンライン訪問者の総数も確実に増えているにもかかわらず、
ほとんど誰からも不具合の連絡が来なくなりました…… 😒


このカスタマーサポートシステムは、私が空き時間に開発を始めてから、気づけば 5年 が経ちました。
この記事では、その5年間の中で何度も私を唖然とさせた――

「え? そんな変な不具合、まだあるの?」

という類の出来事を記録していきます。

え? そんな変な不具合あるの?

たった一つの絵文字が履歴を消し飛ばす:UTF-8 と DB 文字コード不一致問題

「昨日のチャット内容が、今日見たら全部消えてるんですが?!」

深夜までログを追いかけた結果、判明したのは衝撃的な事実でした。
データベースは 正常に 0 件を返している
タイムアウトでもなく、権限エラーでもない。
まるで、そのチャット自体が最初から存在しなかったかのように。

さらに過去ログを辿ると、
そのオペレーターが送った最後のメッセージはこれでした。

「はい、少々お待ちください🥲」

この 小さな 🥲 一文字 が、
そのチャット履歴全体を破壊していた のです。

症状:INSERT は成功したように見えるが、実際には SQL が失敗している。

テーブル作成時、私は安易に「UTF-8」を指定していました。
しかし、MySQL における utf8utf8mb4全く別物だという事実を見落としていました。

CREATE TABLE chat_message (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    visitor_id VARCHAR(50),
    content TEXT CHARACTER SET utf8 COLLATE utf8_general_ci,
    created_at DATETIME
);

訪問者のメッセージに 4バイト文字(emoji や特殊記号) が含まれると、
INSERT は即座に失敗します。

INSERT INTO chat_message (visitor_id, content, created_at)
VALUES ('A123', 'はい、少々お待ちください🥲', NOW());
-- Error: Incorrect string value: '\xF0\x9F\xA5\xB2' for column 'content'

アプリケーション層の「偽の成功」:ドライバが例外を握り潰していた

使用していた .NET MySQL Connector では、
デフォルト設定で IgnorePrepare = true になっており、
さらにコード側で 詳細な SQL 例外を捕捉していなかった ため、

結果として
「INSERT 失敗 → でも成功扱い」
という最悪の状態が発生していました。

try
{
    await db.ExecuteAsync(
        "INSERT INTO chat_message (visitor_id, content, created_at) VALUES (@v, @c, @t)",
        new { v = visitorId, c = content, t = DateTime.UtcNow }
    );
    logger.Info("メッセージ保存成功:" + content);
}
catch (Exception ex)
{
    // SQL エラーコードを出力せず、ex.Message のみ記録 → 上位で無視される
    logger.Warn("メッセージ保存例外:" + ex.Message);
}

そのため「保存できた」と信じていたメッセージは、
翌日オペレーターが確認すると 空っぽ だったわけです。


根本原因:MySQL の utf8 は「3バイト UTF-8」

MySQL の utf8 は、
1〜3バイト(BMP プレーン)までしか対応していません。

一方、emoji は U+1F600 以降のコードポイントを使用し、
4バイト UTF-8 が必要です。

🥲 = U+1F972
UTF-8 バイト列 = F0 9F A5 B2

このため、該当文字を含む INSERT はすべて失敗していました。


解決策:全面的に utf8mb4 へ移行+互換対応

Step 1:DB とテーブルの文字コード変更

ALTER DATABASE mychat CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
ALTER TABLE chat_message CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Step 2:接続文字列で明示的に指定

var connStr = "Server=localhost;Database=mychat;Uid=root;Pwd=xxx;CharSet=utf8mb4;";

Step 3:emoji を含むデータで実動確認

var testMessage = "ShenDeskへようこそ🥳🔥";
await db.ExecuteAsync(
    "INSERT INTO chat_message (visitor_id, content, created_at) VALUES (@v, @c, @t)",
    new { v = "T001", c = testMessage, t = DateTime.UtcNow }
);

var result = await db.QuerySingleAsync<string>(
    "SELECT content FROM chat_message WHERE visitor_id = @v ORDER BY id DESC LIMIT 1",
    new { v = "T001" }
);

Console.WriteLine(result); // 出力: ShenDeskへようこそ🥳🔥

さらに深い落とし穴:utf8mb4 とインデックス長の衝突

utf8mb4 に切り替えた直後、
複合インデックスが突然作成できなくなりました。

ALTER TABLE chat_message ADD INDEX idx_v_c(visitor_id, content);
-- Error: Specified key was too long; max key length is 767 bytes

原因は単純です。
utf8mb4 は 1 文字最大 4 バイト。
VARCHAR(255) をそのままインデックスにすると、
InnoDB の制限を簡単に超えてしまいます。

対策:プレフィックスインデックス、もしくは全文検索

ALTER TABLE chat_message ADD INDEX idx_v_c(visitor_id, content(100));

まとめ:この件から得た教訓

  1. utf8 = UTF-8 だと思うな。 MySQL の utf8 は欠陥仕様。
  2. ログと例外は SQL エラーコードまで正確に記録せよ。
    さもないと「データ消失」に永遠に気づけない。
  3. テストデータには必ず emoji を含めること。
    本番環境では、無数の 🐱🐶 があなたのデータを破壊しに来る。

最終的に、私は以下のテストを追加しました。

it("should store emoji without error", async () => {
    const message = "emoji テスト 🐱🐶🔥";
    const res = await api.sendMessage({ visitorId: "U999", content: message });
    const saved = await api.getLastMessage("U999");
    expect(saved.content).toBe(message);
});

このテストは 毎回のデプロイ時に必ず実行 されます。
もう二度と、たった一つの 🥲 に履歴を壊されないために。


👋 最後に

現在も ShenDesk は進化を続けています。
もしあなたがライブチャットシステムを開発・導入した経験があるなら、
リアルタイム更新、負荷分散、柔軟なデプロイをどのように実現したか、ぜひ教えてください。

一緒に語りましょう。


🚀 ぜひお試しください

🌐 公式サイト:https://shendesk.com
📘 ドキュメント:https://docs.shendesk.com

オンラインでも、自分のサーバーでも、無料で体験できます。
セルフホストやリアルタイム通信、カスタマーエクスペリエンス改善に関心のある開発者からのフィードバックをお待ちしています。


UI スクリーンショット

ビジター側:高速読み込み&メッセージロスなし

ビジター側

オペレーター側:信頼性が高く、機能も充実

オペレーター側

Web管理画面

Web管理画面

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?