作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢!


  • 程序以 debug 的方式运行时,没有任何问题。也就是 dotnet run xx.csproj 时,一切正常。
  • 程序以 AOT 的方式编译为 linux + amd64 模式下的二进制时,发生了问题。

发生问题的核心代码是:

        try
        {
            await cmd.PrepareAsync(ct).ConfigureAwait(false);
        }
        catch (MySqlException ex)
        {
            cmd.Dispose();
            return (null, Error.WithLoc((int)ErrorCodes.PrepareMysqlExceptionError, $"[MySqlException]DbConnection.PrepareAsync: {ex.Message}"));
        }
        catch (OperationCanceledException)
        {
            cmd.Dispose();
            return (null, Error.WithLoc((int)ErrorCodes.PrepareTimeoutError, "[OperationCanceledException]cmd.PrepareAsync timeout"));
        }

部署后的现象是:
一开始启动时,请求后台站点一切正常;等几分钟再请求,会导致后端 coredump,或者接口返回 500 错误。

最后终于抓到了堆栈信息:

Unable to write data to the transport connection: Broken pipe.","stack_trace":"   at Syst  em.Net.Security.SslStream.<<WriteSingleChunk>g__CompleteWriteAsync|166_1>d`1.MoveNext() + 0x88
--- End of stack trace from previous l  ocation ---
   at System.Net.Security.SslStream.<WriteAsyncInternal>d__173`1.MoveNext() + 0x2e1
--- End of stack trace from previous   location ---
   at MySqlConnector.Protocol.Serialization.StreamByteHandler.<<WriteBytesAsync>g__DoWriteBytesAsync|7_0>d.MoveNext() +   0x90
--- End of stack trace from previous location ---
   at MySqlConnector.Protocol.Serialization.ProtocolUtility.<WritePayloadAsy  nc>d__2.MoveNext() + 0xfe
--- End of stack trace from previous location ---
   at MySqlConnector.Core.ServerSession.<SendReplyAsync>  d__122.MoveNext() + 0x200
--- End of stack trace from previous location ---
   at MySqlConnector.Core.ServerSession.<PrepareAsync>d_  _100.MoveNext() + 0x590
--- End of stack trace from previous location ---
   at QiWa.Mysql.DbConnection`3.<GetOrPrepareAsync>d__12.M  oveNext() + 0x145
--- End of stack trace from previous location ---
   at QiWa.Mysql.DbConnection`3.<ExecuteNonQueryAsync>d__13.Move  Next() + 0x2f1
--- End of stack trace from previous location ---
   at CSharpBackend.Global.OnlineUsers.<SaveSessionToMysql>d__2.Mov  eNext() + 0x534

从 Claude 的解读来看,流程是:
1 当 MySqlConnection 对象几分钟未使用,这个 tcp 连接已经失效了;
2 再次使用这个对象时,send 系统调用触发了信号 SIG PIPE,也就是 Broken pipe
3 dotnet 内部会抓取 SIG PIPE 信号,然后抛出 System.IO.IOException 异常;
4 程序中只处理了 MySqlException 和 OperationCanceledException 两种异常,没有处理 System.IO.IOException 异常,导致异常进一步沿着堆栈向上抛;
5 最终(有加处理代码的话),日志中记录了这个异常;
6 (导致这个问题排查困难的核心原因) linux环境中,如果碰巧对 SIG PIPE 这个信号有特殊理解,会导致程序立即崩溃。

  • 例如: programe1 | my_server 这样的启动方式,后一个进程的 sig pipe 信号导致这些程序都崩溃了。

解决办法是:catch 住 System.IO.IOException 类型的异常
代码如下:

        try
        {
            await cmd.PrepareAsync(ct).ConfigureAwait(false);
        }
        catch (MySqlException ex)
        {
            cmd.Dispose();
            return (null, Error.WithLoc((int)ErrorCodes.PrepareMysqlExceptionError, $"[MySqlException]DbConnection.PrepareAsync: {ex.Message}"));
        }
        catch (OperationCanceledException)
        {
            cmd.Dispose();
            return (null, Error.WithLoc((int)ErrorCodes.PrepareTimeoutError, "[OperationCanceledException]cmd.PrepareAsync timeout"));
        }
        catch (System.IO.IOException exIO)
        {
            cmd.Dispose();
            return (null, Error.WithLoc((int)ErrorCodes.PrepareIOExceptionError, $"[System.IO.IOException]Broken pipe, ex={exIO.Message}"));
        }
        catch (Exception exUnknown)
        {
            cmd.Dispose();
            return (null, Error.WithLoc((int)ErrorCodes.PrepareUnknownExceptionError, $"[Exception]ex={exUnknown.Message}"));
        }

教训

  • 使用一个库的时候,不要因为文档上写了"我会抛出异常 ex1, ex2...",就只去捕获这些异常。
    • 本着“不要吃掉异常”的编码原则,我一开始的计划时:会抛出哪些异常,就捕获哪些异常。意料之外的异常,该抛出来还是应该抛出来。
    • 但是:库的设计者并未考虑 System.IO.IOException 这种情况,且又没写到文档上,导致没有在最合适的地方捕获异常。
  • AOT 编译选项与 debug run 的行为差异巨大。如果一个程序最终以 AOT 的形式编译部署,宜及早在这样的编译环境上测试。
    • 在开发数周时间内,我都是以 "dotnet run xx.csproj" 的方式来调试的,没有发现任何异常。
    • 而 AOT 编译中,底层为了提升性能,就会以抛出 SIG PIPE 来代替 debug 模式中的异常处理。
  • linux环境对 SIG PIPE 的处理模式是另一个坑:
    • 程序内部悄悄的抛出了 SIG PIPE 的信号,而容器环境中管道运行模式偏偏又在遇到这个信号时认为程序崩溃了 —— 这个不明原因的程序崩溃使得这个简单的问题查起来迷雾重重。
    • 后来,我通过专门的 debug 基础镜像,然后在 gdb 中启动程序,最终抓到了现场。(后续会专门写一篇帖子来介绍)

最后,我想说:用 Csharp 做后端,与 golang 相比真是差远了。能选 golang 尽可能用 golang.