简介:C#短信群发源码(基于短信猫)面向希望快速搭建批量短信发送功能的开发者,依托短信猫硬件与GSM网络完成短信收发,适用于企业通知、营销触达等场景的学习与二次开发。压缩包共114个文件、约721KB,以38个cs源码、17个resx与17个resources界面资源、7个dll组件及若干gif/jpg素材、mdb数据库和sln解决方案为主,完整呈现从主窗体、发送模块到号码与用户管理的项目结构。已有120人学习下载。读者可从中梳理C#串口通信、AT指令控制、多线程并发发送、短信模板与发送记录存储、异常处理及WinForms界面设计等实现思路,也可对照解决方案与数据库文件理解企业级短信群发系统的模块划分,为同类项目开发提供可复用的参考。
1. 一台插着 SIM 卡的盒子,怎么就成了内网通知系统的最后一公里
去年帮某公司做一套设备告警通知,服务器全在内网,出不了公网,运营商短信接口一个都接不进去,最后落地的方案是机柜里插一台短信猫,用 C# 短信群发源码直接怼串口发 AT 指令。这套源码解决的不是「发一条短信」这种玩具需求,而是批量号码、中文长短信、失败重试、回执落库这一整条链路。它适合两类人:一类是做内网通知、值班告警、会员触达的开发者,另一类是手上已经有 GSM 模块、但被 PDU 编码和串口超时折磨过的同学。下面按拆包的顺序讲,先通信层,再编码,最后群发引擎和那几个必踩的坑。
2. 串口握手与 AT 指令封装:把通信层的抖动先摁住
短信猫本质上是一个挂在串口上的 GSM 模块,主机和它之间只有一件事:你发一行以\r结尾的 AT 指令,它回一段以\r\n分隔的文本。听起来简单,但群发场景下所有稳定性问题几乎都出在这一层——不是编码错,是通道本身不干净。源码里第一个要看的类就是串口通道封装,它决定了后面 PDU 编码写得再漂亮有没有意义。
2.1 TEXT 模式和 PDU 模式:为什么源码默认选后者
模块一般支持两种短信模式。AT+CMGF=1是 TEXT 模式,发短信时直接AT+CMGS="13800000000"然后跟正文;AT+CMGF=0是 PDU 模式,正文要先编码成一串十六进制。TEXT 模式看着省事,实际用起来问题很大:中文的支持依赖模块固件,有的模块要求先AT+CSCS="UCS2"再把正文转成 UCS2 十六进制,有的模块干脆只认 GSM 7bit 字符集,中文直接变问号;而且 TEXT 模式下长短信的拼接基本不可控,模块自己拆不拆、怎么拆全看固件版本。
PDU 模式虽然要自己编码,但所有字段都在你手里——短信中心号、目标号码类型、编码方式、有效期、UDH 头,一个字节都不含糊。同一份源码换不同模块,只要支持标准AT+CMGF=0和AT+CMGS=<len>,行为就是一致的。这就是选型的核心理由:用可控的复杂度换掉不可控的固件差异。所以源码把 PDU 编解码单独抽成一个静态类,通道层只管收发字符串,两层不耦合。
2.2 串口六个参数怎么定:波特率、超时和流控的取舍
串口参数配错,最典型的症状是能打开端口但收到的全是乱码,或者AT发出去石沉大海。下面这张表是这类模块最常见的取值,也是源码里SerialPort构造的默认值:
| 参数 | 典型值 | 说明与踩坑点 |
|---|---|---|
| BaudRate | 115200 / 9600 | 新模块多为 115200,老模块默认 9600,发不通先降速试 |
| DataBits | 8 | 基本不用改 |
| Parity | None | 少数工业模块要求 Even,接错会整帧乱码 |
| StopBits | One | 同上 |
| Handshake | None | 开了硬件流控但线序不对,会直接卡死写操作 |
| ReadTimeout / WriteTimeout | 3000 ms | 别设 -1(无限等待),模块假死时线程会挂死 |
| DtrEnable / RtsEnable | true | 部分模块靠 DTR 判断主机在线,为 false 时拒绝应答 |
有一点值得单独说:NewLine一定要设成"\r",不能是默认的"\n"。AT 指令的终结符是回车,发\n模块会当成无效字符忽略,然后你等OK永远等不到,排查半天以为是模块坏了。这类细节在源码里都写死在构造函数中,不是我藏私,是改错了真的很难查。
2.3 一段能直接跑的 AT 通道封装
下面这段是源码里最核心的通道类,去掉业务逻辑后保留了完整骨架。它解决三个问题:AT 指令必须串行、接收要异步、超时要有明确异常。
using System; using System.IO.Ports; using System.Text; using System.Threading; public sealed class AtChannel : IDisposable { private readonly SerialPort _port; private readonly object _sync = new object(); // AT 指令串行锁,模块不支持并发 private readonly StringBuilder _buffer = new StringBuilder(); // 异步接收缓冲 public AtChannel(string portName, int baudRate = 115200) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout = 3000, WriteTimeout = 3000, DtrEnable = true, // 告诉模块主机已就绪 RtsEnable = true, NewLine = "\r" // AT 终结符是回车,不是换行 }; _port.DataReceived += OnDataReceived; _port.Open(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { try { var chunk = _port.ReadExisting(); lock (_buffer) { _buffer.Append(chunk); } } catch (Exception) { // 拔线、驱动重载时会抛,不在这里处理,交给上层超时判定 } } // 发送一条 AT 指令并等待期望的终结符 public string Send(string command, string expect = "OK", int timeoutMs = 5000) { lock (_sync) { lock (_buffer) { _buffer.Clear(); } _port.Write(command + "\r"); var deadline = Environment.TickCount + timeoutMs; while (Environment.TickCount < deadline) { string snapshot; lock (_buffer) { snapshot = _buffer.ToString(); } if (snapshot.Contains("\r\nERROR") || snapshot.Contains("\r\n+CMS ERROR")) throw new InvalidOperationException($"模块报错: {command} => {snapshot}"); if (snapshot.Contains("\r\n" + expect)) return snapshot; Thread.Sleep(20); // 轮询间隔,太密会吃满 CPU } throw new TimeoutException($"AT 超时: {command}"); } } public void Dispose() { _port.DataReceived -= OnDataReceived; try { if (_port.IsOpen) _port.Close(); } catch { } _port.Dispose(); } }逻辑说明:Send里的锁保证同一时刻只有一条指令在飞,这是必须的——模块的应答不带指令标识,两条指令交叉发送后你根本分不清哪个OK对应哪条。接收端用DataReceived事件把数据攒进缓冲区,主流程只轮询缓冲区内容,避免同步ReadLine在超时边界上丢数据。参数上timeoutMs默认 5000,发短信的AT+CMGS要传更长(下面会讲),因为写短信到 SIM 卡可能耗时两三秒。
2.4 上电握手序列与模块状态自检
串口打开只是物理层通了,模块能不能用还要走一遍握手。源码里Initialize方法的调用顺序是固定的,顺序错了会出各种莫名其妙的问题:
// 1. 探活:模块可能还在上电自检,重试几次 channel.Send("AT", "OK", 2000); // 2. 关回显:不关的话每条指令都会被原样回显,污染缓冲区判断 channel.Send("ATE0", "OK"); // 3. 切 PDU 模式 channel.Send("AT+CMGF=0", "OK"); // 4. 指定短信存储位置:优先 SIM 卡,容量小但掉电不丢 channel.Send("AT+CPMS=\"SM\",\"SM\",\"SM\"", "OK"); // 5. 让新短信主动上报,而不是靠轮询 channel.Send("AT+CNMI=2,1,0,0,0", "OK");第 5 步的AT+CNMI=2,1,0,0,0值得展开:第一个参数 2 表示新短信到达时先存起来再上报,第二个参数 1 表示用+CMTI: "SM",<index>的形式通知。不设这个的话,模块默认不主动告诉你来短信了,只能定时AT+CMGL轮询,实时性和资源占用都差。
握手完还要做两项自检,这两项不做,后面群发失败你都不知道从哪查:
AT+CSQ返回信号强度,格式是+CSQ: 20,99,第一个值 0~31,低于 10 就别指望稳定发送了,先去调天线位置;AT+CREG?返回+CREG: 0,1,第二个值 1 表示已注册到网络,5 表示漫游。如果是 0 或 3,说明卡没插好、欠费或者被锁,这时候发什么都是ERROR。
还有一个容易被忽略的:AT+CSCA?查短信中心号。这个号是存在 SIM 卡里的,换卡、换批次卡之后可能是空的或者旧的,表现就是模块返回OK、+CMGS也给了消息参考号,但对方永远收不到——这是第 5 章要详细讲的第一个大坑。
3. PDU 编解码与长短信拆分:一条中文短信为什么卡在 70 个字
编码这层是整份源码里最值得逐字节看的部分。很多人拿到源码后能跑通英文,一换中文就废,或者超过 70 个字就被截断,根子都在这里。PDU 不是什么私有协议,它是 GSM 03.40 定义的标准格式,字段顺序固定,理解了结构就能自己改。
3.1 一条中文短信在 PDU 里长什么样
先看一条实际报文(发送方向,SMS-SUBMIT):
00 11 00 0B 81 3108000000F0 00 08 AA 0C 4F60597D拆开对照着看:
| 字段 | 值 | 含义 |
|---|---|---|
| SCA 长度 | 00 | 用模块内已设置的短信中心,不在这里重复带 |
| PDU 类型 | 11 | SMS-SUBMIT,带相对有效期 |
| MR | 00 | 消息参考号,模块自己递增 |
| DA 长度 | 0B | 目标号码有 11 位十进制数字 |
| DA 类型 | 81 | 国内号码;带 + 的国际号是 91 |
| DA 号码 | 3108000000F0 | 半字节交换后的号码,奇数位补 F |
| PID | 00 | 协议标识,普通短信填 0 |
| DCS | 08 | 数据编码方案,08 = UCS2,中文必须用它 |
| VP | AA | 相对有效期,AA 约等于 4 天 |
| UD 长度 | 0C | 用户数据字节数,12 字节 = 6 个汉字 |
| UD | 4F60597D | UCS2 大端编码的正文 |
这里有两个必须记住的点。第一,AT+CMGS的参数不是整条 PDU 的长度,而是UD 的字节数,也就是上表里的0C对应 12,指令要写成AT+CMGS=12。写错这个数值,模块要么报错要么发出乱码。第二,DA号码是半字节交换的,138...会变成31 08 ...这种形式,奇数位末尾补F,这个转换写错一位,短信就发给了别人或者直接ERROR。
3.2 号码字段的半字节交换与国内国际号型
号码编码是第一个容易翻车的地方,单独抽出来看。转换规则是每两位数字颠倒顺序,如果位数是奇数,末尾补F:
// 目标号码 -> PDU 的 DA 字段(不含长度字节) private static string EncodeAddress(string number, bool international) { var type = international ? "91" : "81"; // 91 带 +,81 国内 var digits = number.TrimStart('+'); var sb = new StringBuilder(); sb.Append(digits.Length.ToString("X2")); sb.Append(type); for (int i = 0; i < digits.Length; i += 2) { var low = digits[i]; var high = (i + 1 < digits.Length) ? digits[i + 1] : 'F'; // 奇数位补 F sb.Append(high).Append(low); // 半字节交换 } return sb.ToString(); }逻辑说明:循环每次取两位,先输出后一位再输出前一位,这就是「交换」的字面含义。参数international决定类型字节,带+86的号码走 91,纯 11 位号码走 81。源码里在入库阶段就把号码归一化了——统一去掉空格、横杠、括号,然后按是否以+开头打标记,避免发送时临时判断出错。
提示:号码库里混着
13800000000、+8613800000000、013800000000三种写法是常态,归一化一定要在入库前做,别留到编码阶段。运营商侧对+86前缀的处理不完全一致,源码默认全按国内号型发。
3.3 UCS2 编码与 70 字上限的由来
中文走 UCS2,每个字符固定 2 字节,而一条短信的 UD 字段最大 140 字节,所以 140 / 2 = 70 个字。这个 70 不是产品经理拍的,是协议算出来的。对应的编码在 C# 里一行就够,但要注意必须是大端:
// 正文 -> UCS2 十六进制串 public static string EncodeUcs2(string text) { var bytes = Encoding.BigEndianUnicode.GetBytes(text); // 大端,不能用默认的小端 return BitConverter.ToString(bytes).Replace("-", string.Empty); }Encoding.Unicode在 .NET 里是小端,直接用它编出来的 PDU 发给模块,对方收到的是字节颠倒的乱码。这个坑我见过不止一次,症状是中文短信显示成生僻字或者方块。源码里把这个方法单独封装,全项目只允许从这里出 UCS2,就是为了堵住随手写Encoding.Unicode的口子。
另外提醒一句,中文标点、全角字符、emoji 都按 UCS2 双字节算。带 emoji 的正文在部分老模块上会被拆成代理对(4 字节),字数上限还要再降,源码里对这类字符做了检测,超出就按长短信拆。
3.4 长短信 UDH 拆分:67 字、参考号和段序
超过 70 个字就要拆长短信,靠的是 UDH(用户数据头)。原理是在每段的 UD 前面插入 6 个字节的头:
05 00 03 <ref> <total> <seq>05是 UDH 长度,00是信息元素标识(拼接长短信的固定值),03是后续字节数,ref是这组短信的参考号(同一组必须一致,1 字节),total是总段数,seq是当前段序号(从 1 开始)。因为占了 6 个字节,每段可用 UD 变成 140 - 6 = 134 字节,也就是67 个汉字。
// 长短信拆分:返回每段的 PDU 用户数据(含 UDH) public static List<string> SplitLongSms(string text, byte reference) { const int perPart = 67; // (140 - 6) / 2 var parts = new List<string>(); var total = (text.Length + perPart - 1) / perPart; if (total > 255) throw new ArgumentException("超出单条长短信最大段数"); for (int i = 0; i < total; i++) { var seg = text.Substring(i * perPart, Math.Min(perPart, text.Length - i * perPart)); var udh = $"050003{reference:X2}{total:X2}{(i + 1):X2}"; // 段序从 1 开始 parts.Add(udh + EncodeUcs2(seg)); } return parts; }逻辑说明:参考号用 1 字节,源码里按「业务批次 + 时间戳」哈希生成,保证同一批群发的不同接收人之间不撞号。段序从 1 开始而不是 0,这一点各模块实现不完全统一,但按标准写从 1 开始兼容性最好。
这里有个隐藏前提:模块必须支持 UDHI(用户数据头标识)。发长短信时AT+CMGS前面的AT+CSMP参数里要带上 UDHI 位,常用配置是AT+CSMP=49,167,0,8,其中第一个参数 49 = 0x31,包含了 UDHI 标志。不设这个,模块会把 UDH 当成正文的一部分发出去,对方收到一串奇怪字符加正文碎片。源码在初始化后立刻下发这条指令,属于必做项。
3.5 回执读取:+CMGL 与 +CDS 两条路
发出去不等于送到。回执有两种拿法:
第一种是主动查,AT+CMGL=4列出所有短信(4 表示全部),返回的是 PDU 格式,需要自己解码状态报告。这种方式的缺点是轮询有延迟,而且存储满了会顶掉新短信。
第二种是让模块主动推,靠AT+CNMI的第二个参数把+CDS(状态报告)推上来。源码默认走这条路,收到+CDS:后解析出消息参考号和状态码,回写数据库。状态码里 0 表示已送达,其他值对应各种失败原因,比如 0x40 以上多为网络侧拒绝。
提示:短信存储在 SIM 卡上容量很小(通常 20~50 条),如果只发不删,跑到几十条就会
+CMS ERROR: 322(存储满)。源码在每次读取回执后立刻执行AT+CMGD=<index>删除,这是群发能长时间跑下去的必要动作。
4. 群发引擎的队列与回执:从逐条 for 循环到可重入发送器
前面两层搞定后,最容易写错的反而是业务层。我见过太多人拿到源码后把发送逻辑改成foreach (var phone in list) { Send(phone); },跑两百条就出问题——不是模块扛不住,是这样写完全没有失败处理、没有节流、没有断点恢复,一次卡死就要从头再来。
4.1 为什么群发不能写成 for 循环
串口是独占资源,一条AT+CMGS从下发到等回OK大概 1~3 秒,一千条就是半小时起步。在这个时间跨度里,网络会抖、模块会热、USB 转串口可能掉线、运营商会限速。for 循环的写法有三个致命问题:一是失败即中断,第 300 条超时了后面 700 条全不发;二是没有速率控制,短时间内高频发送直接触发风控;三是进度不可观测,发到哪一条全靠日志肉眼数。
所以源码把发送拆成「任务表 + 队列 + 消费者」三层。任务表落库,队列管调度,消费者负责真正怼串口。任何一层挂了,其他两层还在,重启后能从断点继续。
4.2 发送队列的状态机设计
每条短信在库里有一个明确的状态,流转是单向的:
| 状态 | 含义 | 可流转到 |
|---|---|---|
| Pending | 待发送 | Sending / Cancelled |
| Sending | 已下发 AT+CMGS,等回执 | Sent / Failed |
| Sent | 模块确认已提交网络 | Delivered / Undelivered |
| Failed | 发送失败,进入重试判断 | Pending(重试)/ Dead(放弃) |
关键在Sent和Delivered是两个不同状态。Sent只代表模块把短信交给了网络,不代表对方收到;Delivered要等+CDS状态报告回来。源码里这两步分开落库,就是为了后面能算出真实送达率——第 6 章会用这份数据。
队列实现用BlockingCollection就够了,不必上消息中间件:
public sealed class SmsDispatcher { private readonly BlockingCollection<SmsTask> _queue = new BlockingCollection<SmsTask>(2000); private readonly AtChannel _channel; private readonly int _intervalMs; // 条间隔,控制发送速率 private readonly Action<SmsTask> _persist; // 状态落库回调 public SmsDispatcher(AtChannel channel, int intervalMs, Action<SmsTask> persist) { _channel = channel; _intervalMs = intervalMs; _persist = persist; } public void Enqueue(SmsTask task) { task.State = SmsState.Pending; _persist(task); _queue.Add(task); // 有界队列,满了会阻塞生产者,天然背压 } // 后台消费者,单独线程跑,别放在 UI 线程 public void Run(CancellationToken token) { foreach (var task in _queue.GetConsumingEnumerable(token)) { try { task.State = SmsState.Sending; _persist(task); SendOne(task); // 内部做 PDU 编码和 AT+CMGS task.State = SmsState.Sent; } catch (Exception ex) { task.LastError = ex.Message; task.State = SmsState.Failed; } finally { _persist(task); // 无论成败都落库,断点恢复靠它 Thread.Sleep(_intervalMs); // 节流,也是给模块喘口气 } } } }逻辑说明:BlockingCollection设了 2000 的容量上限,生产者灌太快会阻塞,这是故意的——总比内存里堆十万条然后 OOM 强。finally里的落库是断点恢复的关键,进程被杀之前最后一条任务的状态一定是 Persisted。_intervalMs是节流参数,取值在下一章有实测对比。
4.3 失败重试的边界:哪些错该重试,哪些不该
无脑重试是最常见的错误。号码非法、被拉黑、内容违规这类错误,重试一百次也是失败,还把队列堵死。源码把失败分成两类:
private static bool IsRetryable(string error) { if (string.IsNullOrEmpty(error)) return true; // 模块/链路层问题,值得重试 if (error.Contains("超时") || error.Contains("Timeout")) return true; if (error.Contains("+CMS ERROR: 500")) return true; // 未知错误,常见于瞬时拥塞 if (error.Contains("+CMS ERROR: 331")) return true; // 网络未响应 // 号码、内容、权限类问题,重试无意义 if (error.Contains("+CMS ERROR: 321")) return false; // 号码格式非法 if (error.Contains("+CMS ERROR: 322")) return false; // 存储满,得先删 return false; }重试策略用的是指数退避:第一次失败等 5 秒,第二次 30 秒,第三次 5 分钟,最多三次,三次都失败标记Dead并告警。这样既不会因为一次网络抖动丢短信,也不会让坏号码反复挤占发送窗口。+CMS ERROR: 322特别说明一下,它不是真正的发送失败,是存储满了,正确做法是先清存储再重发,源码在捕获这个错误时会插一个清理动作。
4.4 落库与断点续发
群发任务的表结构不复杂,但有两列必须加索引:
CREATE TABLE sms_task ( id BIGINT PRIMARY KEY, batch_id VARCHAR(32) NOT NULL, -- 批次号,一次群发一个 phone VARCHAR(20) NOT NULL, content VARCHAR(1000) NOT NULL, state TINYINT NOT NULL DEFAULT 0, -- 0待发 1发送中 2已提交 3已送达 4失败 retry_count TINYINT NOT NULL DEFAULT 0, msg_ref INT NULL, -- 模块返回的消息参考号,用于对回执 last_error VARCHAR(255) NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_batch_state (batch_id, state), KEY idx_msg_ref (msg_ref) );msg_ref这列是把+CDS回执关回任务记录的钥匙。AT+CMGS成功后会返回+CMGS: <mr>,把这个数字存进去,等状态报告上来时按mr反查,就能把Sent更新成Delivered。没有这一列,回执就是一堆无主数据。
断点续发逻辑很简单:进程启动时查state IN (0,1)的记录重新入队。state=1的那些是发送中途进程被杀的,重发有可能造成重复,所以源码在重发前先按msg_ref查一次回执表,能对上就跳过。这个判断不完美,但比无条件重发好得多。
5. 避坑与排查:串口假死、回执丢包和送达率的 5 个真实翻车点
这一章按「现象 → 原因 → 解决」来写,都是这套源码在实际部署里真出过的问题。有些坑跟代码无关,是硬件和网络侧的,光看代码永远查不出来。
5.1 发出去显示成功,对方永远收不到
现象:AT+CMGS返回OK和+CMGS: 12,数据库状态是Sent,但所有接收方都没收到,+CDS回执也一直不来。
原因:短信中心号(SMSC)为空或指向一个已经停用的旧号。换过 SIM 卡、或者卡是批量采购的物联网卡时特别常见,卡里预置的中心号可能压根没写。模块不会因为缺中心号就拒绝提交,它会照常收下并给参考号,然后短信在模块内部排队等中心号,等不到就一直挂着。
解决:初始化后强制查一次AT+CSCA?,返回值空或者不是预期号段就AT+CSCA="<号码>",145写入(145 表示国际格式),写完重启模块才生效。源码里把这一步做成了启动自检,不通过直接拒绝启动发送服务。
5.2 跑两三个小时后串口假死
现象:前几百条正常,之后Send开始超时,Write不报错但缓冲区再无新数据进来,重启程序又能跑一段时间。
原因:两个可能叠加。一是 USB 转串口芯片在长时间高频通信下会掉帧甚至驱动重载,SerialPort对象看着还 Open,实际句柄已经失效;二是接收线程只ReadExisting不做超时清理,某次半包数据卡在缓冲区里,后续判断全被污染。
解决:加看门狗。连续三次 AT 超时就判定通道失效,执行Close()+ 短延时 +Open()+ 重新握手。同时换用带独立供电的 USB HUB,别让模块和键盘鼠标抢一个口。源码里AtChannel暴露了一个Reopen()方法就是干这个的,上层消费者捕获TimeoutException累计三次后调用。
提示:不要用
SerialPort.Close()之后立刻Open(),Windows 下句柄释放有延迟,至少Thread.Sleep(500)。这个 500 毫秒是血泪经验,省了它会出现「重新打开成功但发什么都 ERROR」的玄学现象。
5.3 中文乱码与问号短信
现象:英文数字正常,中文对方收到一串问号,或者干脆变成长度不对的乱码。
原因:三种可能。一是没切 PDU 模式,还在 TEXT 模式下用 GB2312 编;二是 UCS2 用了Encoding.Unicode(小端)而不是BigEndianUnicode;三是AT+CMGS后面的长度参数填了整条 PDU 的长度,而不是 UD 的字节数。
解决:逐项核对。AT+CMGF=0必须确认返回OK;编码统一走EncodeUcs2一个出口;AT+CMGS参数严格等于 UD 字段的字节数(长短信时含 UDH 那 6 个字节)。可以先用一条「测试」两个字的短信单发验证,别一上来就群发。
5.4 长短信只到一段或顺序颠倒
现象:160 字的内容,对方只收到前 67 字,或者收到三段但顺序乱的。
原因:UDH 里的参考号每段不一致(常见于循环里每次重新生成 ref),或者段序从 0 开始而模块按 1 开始解析,或者AT+CSMP没带 UDHI 标志导致 UDH 被当正文发出去。
解决:同一批长短信必须共用同一个 ref,total和seq严格配对,seq从 1 起。发送前先下发AT+CSMP=49,167,0,8打开 UDHI。验证方法是给自己发一条 200 字的内容,看看手机上是不是完整显示成一条——多数手机会自动拼接,收到三段独立短信说明网络侧没识别到 UDH。
5.5 批量发送中途被限速
现象:前 200 条很顺,之后连续ERROR,或者短信状态长期停在Sent不转Delivered。
原因:发送速率超过了卡所在的套餐或通道阈值,触发网络侧流控。物联网卡、行业卡对发送频次限制尤其严格。内容里带明显营销词也会被内容侧拦截,表现同样是发出去没回执。
解决:把_intervalMs调大是最直接的手段,具体取值看下一章的实测表。另外号码分批,一个批次别超过 500 条,批之间留间隔。被限速后不要立即重试,让队列暂停 10~30 分钟再恢复,硬冲只会让限制时间更长。
6. 提速与验证:把发送速率从 6 条/分推到模块上限
群发跑起来之后,所有问题最后都会收敛到一个指标:单位时间能送达多少条,以及这个数字能不能信。我一般不从「发了多少条」看,而是从库里三列算:Sent数、Delivered数、Dead数,真实送达率 = Delivered / (Sent + Dead),把还在Sending的排除掉。这个口径比模块日志准,因为模块日志里+CMGS返回 OK 只代表提交,不代表有人收到。
速率方面,_intervalMs的取值需要实测而不是拍脑袋。同一台模块、同一张卡,间隔和成功率的关系大致是这样的(每档各发 200 条,连续跑三轮取平均):
| 间隔 | 理论速率 | 实测提交成功率 | 实测送达率 | 结论 |
|---|---|---|---|---|
| 300 ms | ~200 条/分 | 87% | 71% | 触发流控,不建议 |
| 800 ms | ~75 条/分 | 99% | 93% | 短时任务可用 |
| 1500 ms | ~40 条/分 | 100% | 96% | 长期运行的稳妥档 |
| 3000 ms | ~20 条/分 | 100% | 97% | 送达率提升有限,性价比低 |
数据里有意思的地方是:间隔从 300ms 提到 800ms,送达率涨了 22 个点;再从 1500ms 放到 3000ms,只涨 1 个点。说明瓶颈在触发阈值,不在绝对速度。所以源码默认_intervalMs = 1500,需要赶时间时临时降到 800,但绝不降到 500 以下。
另一个进阶用法是模块热备。单台模块跑久了总要重启,加第二台做冷备,主模块连续三次超时就切到备机,切换逻辑就放在AtChannel.Reopen()失败之后:
// 主备切换:主通道重连失败则切备通道 if (!primary.TryReopen()) { logger.Warn("主模块重连失败,切换备模块"); dispatcher.SwitchChannel(secondary); // 备模块需提前完成 Initialize }切换时要注意备机也在同一张卡池里,别让两台同时发同一个批次,否则会重复。源码用batch_id加一把分布式锁解决,本地单机部署的话一个lock对象就够了。
最后说个验证送达率的小技巧:在批次里混入 2~3 个自己的号码,人工确认收到情况和文案完整性。机器告诉你Delivered,但拼接顺序错、字数截断、标点变问号这些只有人眼能看出来。这个习惯我保持了三年——从那以后,我每次上线新的模板或换模块,都强制先发一轮自测号,看实际收到的样子,再放量。希望帮到你。
本文还有配套的精品资源,点击获取