
去年我给一个内部系统加监控报警时最先想到的是在群里发消息。结果报警频率一高群里全是机器人刷屏值班的同事直接把群消息屏蔽了。后来换成邮件邮件又进了垃圾箱或者常规延迟二十分钟——等看到邮件服务器早挂了。折腾一圈最后老老实实接上短信。这个选择看起来复古但实际体验下来短信在通知链路里至今不可替代送达率高、延迟低、不需要用户装App、也不需要用户打开推送权限。这篇文章就从零开始用 Python 和 Twilio 把一套短信通知系统搭起来。内容覆盖账号开通、号码格式、SDK 封装、验证码场景、状态回执和生产环境的限流与成本控制适合刚接触 Twilio、或者准备在项目里接入短信通知的开发者参考。1. 短信通知不是复古是刚需先聊聊怎么选型很多人听到短信通知第一反应是过时。但你先别急着下结论我花了不少时间比较过各种通知渠道的体验最后发现每个渠道都有自己的死穴。1.1 邮件、推送、群机器人为什么都差点意思邮件的问题在于延迟和过滤。自建邮件的到达率受SPF、DKIM、IP 信誉影响很大偶尔一条关键的报警邮件被归进垃圾箱整个监控体系就白搭了。而使用大厂SMTP服务只是把垃圾箱的概率降低并没有根除延迟问题。对于线上故障来说每一分钟都很贵。App 推送的问题在于权限和活跃度。用户可能关掉通知权限可能换了手机没重新登录可能根本装不了内部应用。你要把一套业务给员工用还需要专门维护应用签名、证书过期等一堆事。IM 群机器人不管是钉钉、飞书还是 Slack在大部分场景下是够用的但有个隐蔽问题群成员会变动离职的人会把群带走群消息被手动归档后就不再有强提醒效果。更重要的是如果你对接的是外部用户比如给客户发订单通知IM 机器人完全不成立。短信就不一样了。运营商级管道只要手机号有效、信号正常短信基本必达。而且它天然适合做验证码、订单状态、紧急报警这类短消息不需要用户做任何额外操作。1.2 Twilio 在这个领域里是什么定位Twilio 是国外主流的云通信平台提供短信、语音、视频等 API。在短信这个单项上它的优势很明确按条计费没有月租调试环境友好Python SDK 非常成熟文档里可以直接跑通最小示例消息有完整的生命周期回调从 queued 到 delivered 每一步都能查到号码覆盖国家和地区非常广跨境场景方便不需要自建短信网关也没有运营商对接资质问题。国内也有阿里云、腾讯云的短信服务如果你的用户全覆盖在中国大陆那国内服务商在实名、模板审核、价格上反而更合适。Twilio 更适合的场景是已有海外业务、有海外手机号接收、或者想快速做一个跨地区的原型验证。这篇文章用 Twilio 做演示是因为它的 API 和状态机制比较规范理解了它换国内厂商就是换 SDK 和参数的事。2. 账号开通与第一条短信五个最容易卡住的环节Twilio 注册本身不复杂但我在第一次操作时还是遇到了几个之前没预料到的卡点。把这几个关键环节捋一遍你至少节省一小时。2.1 注册、登录与试用金在官网注册时需要提供邮箱和密码。注册后 Twilio 会要求你验证手机号——这一步是收短信验证码验证完成后进入控制台创建项目。新账户有少量试用金不同时期的政策略有差异一般足够你发出几十条测试短信这部分试用金够你把流程跑通。注意试用账户模式下只能向已验证过身份的号码发送短信这就引出了第一个坑。2.2 试用模式的限制必须先验证接收号码刚开始我在控制台里买了一个号码然后试图直接往自己手机上发短信一直报错。后来才发现试用项目只能发送给已经验证过的手机号。你自己注册时验证过的手机号其实已经处于已验证状态所以最稳妥的做法是注册时的手机号就用你后续要测试的号码这样省一步操作。如果你需要给其他手机号测试进入控制台后找到 Verified numbers验证号码页面添加号码并完成短信验证即可。等账户转入付费模式之后这个限制会自动解除。2.3 购买一个发件号码短信不能凭空发出你需要先有一个发件人号码From 号码。在控制台的 Phone Numbers 页面可以购买号码。选择号码时有三件事要考虑国家、号码类型、能力。本地号码Local一般月租最低移动号码Mobile在某些国家支持更多类型的内容免费号码Toll-Free适合做客户回执场景但月租更高。能力部分确认勾选 SMS 就好Voice 按需勾选。2.4 最容易报错的 E.164 号码格式这是新手必踩的坑。Twilio 要求所有电话号码必须是 E.164 国际格式也就是国家代码 不带首位 0 的区号 本地号码。中国大陆手机号 13800138000写成 E.164 就是 8613800138000。注意最前面的 号不能省略国内习惯写的 0138... 或 86138 都不对。如果不小心写成 13800138000SDK 会抛一个 21211 错误Invalid To Phone Number。这个报错占了我初期调试问题的一半写代码前先把号码格式这块确认好。2.5 管理凭据Account SID 和 Auth Token 放在哪里进入控制台首页你会看到 Account SID 和 Auth Token 两串字符。Auth Token 相当于密码任何人拿到都能用你的账号发短信、打电话所以千万不要硬编码在代码里更不能提交到 Git 仓库。我的习惯是在项目根目录建一个 .env 文件用环境变量加载export TWILIO_ACCOUNT_SIDACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx export TWILIO_AUTH_TOKENyour_auth_token_here export TWILIO_FROM_NUMBER12025550123后面所有代码都通过环境变量读取配置这样换账号、换号码都不用改代码。3. 从发出一条短信到封装成通知模块这层代码该怎么写官方文档给了最小示例但实际项目里不可能直接往业务逻辑里塞一个 Client。你需要的是把发送短信抽象成一个可复用的模块下面是我在项目里用的一套封装思路。3.1 最简发送代码先跑通再说安装 SDK 和发送短信的代码非常简单pip install twiliofrom twilio.rest import Client account_sid ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx auth_token your_auth_token_here client Client(account_sid, auth_token) message client.messages.create( bodyHello from Twilio, from_12025550123, to8613800138000 ) print(message.sid)能跑通这一步恭喜整个系统最核心的部分已经完成了。但注意这里的 account_sid 和 auth_token 是硬编码只适合本机验证不要出现在正式代码里。3.2 封装一个 SmsNotifier 类环境变量、日志、异常处理把上面的逻辑封装成类让它具备读取环境变量、记录日志、捕获异常的能力。下面是完整代码import os import time import logging from twilio.rest import Client from twilio.base.exceptions import TwilioRestException logger logging.getLogger(__name__) class SmsNotifier: def __init__(self): self.account_sid os.getenv(TWILIO_ACCOUNT_SID) self.auth_token os.getenv(TWILIO_AUTH_TOKEN) self.from_number os.getenv(TWILIO_FROM_NUMBER) if not all([self.account_sid, self.auth_token, self.from_number]): raise RuntimeError( Missing Twilio config. Please set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER. ) self.client Client(self.account_sid, self.auth_token) def send(self, to_number: str, body: str, max_retry: int 3) - str: last_error None for attempt in range(max_retry): try: message self.client.messages.create( bodybody, from_self.from_number, toto_number ) logger.info( SMS sent: to%s sid%s attempt%d, to_number, message.sid, attempt 1 ) return message.sid except TwilioRestException as exc: last_error exc logger.warning( SMS send failed: to%s code%s status%s attempt%d, to_number, exc.code, exc.status, attempt 1 ) if exc.code in (21211, 21408, 21610): break # 号码格式错/地区受限这类错误重试无效直接终止 time.sleep(2 ** attempt) # 指数退避1s, 2s, 4s raise RuntimeError(fSMS send failed after retries: {last_error})有几个点值得展开说一下为什么要 catch TwilioRestException 而不是直接放行短信发送是外部 IO网络波动和运营商瞬时拒绝都很常见。对瞬时错误做指数退避重试能显著降低整体失败率。但 21211号码格式错误、21408目标地区未授权、21610运营商拒绝发送到该国属于配置或业务问题重发多少次都一样所以直接 break 退出重试循环。为什么用 environment variable 而不是配置文件因为 Auth Token 属于敏感信息不应该出现在源码里。用环境变量可以方便地在不同环境本地、测试、生产切换配置也不影响 Docker 等部署方式。3.3 避免阻塞把发送任务扔进线程池如果你的短信模块是在 Web 请求里同步调用的比如用户点击获取验证码按钮后端同步发短信会有个问题Twilio API 的平均延迟在几百毫秒到 2 秒之间在极端情况下可能更久。这个时间用户拿着手机在等页面一直转圈体验很差而且如果请求量大同步调用会把 Web 进程的线程全部占满。解决思路是把发送任务异步化。最简单的方式是用 ThreadPoolExecutorfrom concurrent.futures import ThreadPoolExecutor executor ThreadPoolExecutor(max_workers4) notifier SmsNotifier() def send_sms_async(to_number: str, body: str) - None: executor.submit(notifier.send, to_number, body)调用方只需要 send_sms_async(8613800138000, 您的验证码是 123456)立即返回不阻塞请求线程。如果项目里已经有 Celery 或者 RQ也可以把发送任务丢进任务队列道理是一样的。注意线程池的 worker 数量要控制好Twilio 对发送速率有限制后面第 5 节会详细说。3.4 重试策略的边界消息可能重复业务层要有幂等兜底异步 重试会带来一个新问题消息可能重复。比如第一次请求超时但 Twilio 实际已经发出去了重试就发了两遍。对于验证码场景用户会收到两条短信虽然不致命但体验很怪。更稳妥的方案是把发送任务记录到数据库附带 message_sid 和发送状态由后台任务查询状态回执再决定是否需要补发。这个思路在第 4 节状态回执部分还会提到。4. 验证码与订单通知把发短信变成一项可靠业务能力等到你真正做验证码和订单通知会发现发出去不等于送达了更不等于用户收到了。短信服务要作为一个可靠的业务能力来设计至少要考虑验证码生命周期、发送频率限制、状态回执三个维度。4.1 验证码的生成与校验别在代码里临时造轮子验证码的正确流程分四步生成、存储、发送、校验。生成部分用 Python 的 secrets 模块不要用 random因为 random 是伪随机数生成器在某些场景下能预测序列import secrets def generate_code(length: int 6) - str: return f{secrets.randbelow(10 ** length):0{length}d}存储部分生产环境建议放 Redis设置过期时间 5 分钟import redis r redis.Redis(hostlocalhost, port6379, db0) def save_code(phone: str, code: str) - None: key fsms:code:{phone} r.set(key, code, ex300)校验时从 Redis 取出再比较一次性使用比对完立即删除。同时记录失败次数同一个号码连续失败 5 次就锁定防止暴力破解。def verify_code(phone: str, code: str) - bool: key fsms:code:{phone} stored r.get(key) if stored is None: return False r.delete(key) return secrets.compare_digest(stored.decode(), code)这里用 compare_digest 而不是 是为了避免时序攻击。虽然短信验证码本身风险不大但养成好习惯没坏处。4.2 发送频率限制短信 API 再好也经不起业务层裸奔短信是有成本的而且和账号信誉直接挂钩。如果接口没有频率限制很可能被脚本刷爆一晚上发出几千条验证码轻则钱包损失重则 Twilio 账号被风控锁定。这个限流必须在业务层做不能依赖 Twilio 那边的配额。def allow_send(phone: str) - bool: key fsms:rate:{phone} current r.get(key) if current is not None: ttl r.ttl(key) return False, ttl r.set(key, 1, ex60) return True, 0上面这个函数实现的是同一手机号 60 秒只能发送 1 次。更完整的方案还要叠加 IP 维度限制同一个 IP 每分钟最多请求 10 次验证码发送。再加一道前端的图形验证码或者滑块验证让脚本没法轻易触发短信接口。三层叠加下来短信被刷的风险基本可控。4.3 状态回执从 queued 到 delivered 的完整链路Twilio 的每条消息都有一个生命周期queued已入队→ sending发送中→ sent已提交运营商→ delivered已送达。此外还有 undelivered 和 failed。大部分业务只需要关心两个状态delivered 代表用户手机收到了failed 代表发送失败需要处理。同步获取状态的方法是主动拉取message notifier.client.messages.get(message_sid) print(message.status) # queued / sending / sent / delivered / failed print(message.error_code, message.error_message)更推荐的方式是设置 StatusCallback 回调地址。Twilio 在消息状态变化时会往这个 URL 发一个 POST 请求。你用 Flask 写一个简单接口接收即可from flask import Flask, request app Flask(__name__) app.route(/twilio/status, methods[POST]) def twilio_status(): data request.form message_sid data.get(MessageSid) status data.get(MessageStatus) error_code data.get(ErrorCode, ) error_message data.get(ErrorMessage, ) # 把结果写入数据库标记订单状态或触发补偿 print(message_sid, status, error_code, error_message) return OK在发送短信时把这个回调地址传进去notifier.client.messages.create( body您的订单已发货, from_notifier.from_number, toto_number, status_callbackhttps://your-domain.com/twilio/status )拿到状态回执后做什么以订单通知为例如果 2 小时内还是 failed就把这条记录推到人工处理队列客服手动跟进。这样一个简单的补发逻辑就能让整个通知系统的可靠性上一个台阶。5. 上线之后必须要管的四件事成本、限速、凭据安全、状态回执短信模块写完了能发能收这是能跑阶段。从能跑到能上线还有四个现实问题要面对。我在生产环境里踩过相关的坑逐个说。5.1 成本一条短信到底多少钱Twilio 按条计费价格因目的地国家和运营商直销/联调模式而不同。发往美国、加拿大的短信大约每条 0.0079 美元发往中国大陆通常在 0.05 美元左右实际价格随运营商策略浮动以控制台价格页为准。做个粗略估算假设每天发 100 条验证码每条 0.05 美元一个月就是 150 美元。如果量大务必要做成本监控。Twilio 控制台有用量报表可以按日期、按国家、按消息类型筛选。我的习惯是每周看一次趋势发现某天用量异常暴涨立刻去查是业务增长还是脚本刷接口。如果是后者说明限流策略没覆盖到需要迅速补上。5.2 限速Twilio 不是无限流水线Twilio 对单个号码的发送速率有默认限制大约每秒 1 条并发请求超过后会报 429 Too Many Requests 或进入排队等待状态。大批量发送场景下会把消息攒在 Twilio 侧慢慢消费时效性变差也容易触发运营商的流量过滤。两个处理方向提高单号码限额在 Twilio 控制台申请提高每号码每秒的发送速率但需要审核购买多个号码并用 Messaging ServiceCopilot管理。Messaging Service 会做号码池管理、自动轮换发件号码、分配发送负载还能统一处理状态回调。自己代码端也要控制发送节奏。我常用的做法是用一个简单的队列 定时发送器按照固定速率把消息限速发出import time from collections import deque class RateLimiter: def __init__(self, max_per_second: float 1.0): self.interval 1.0 / max_per_second self._queue deque() def wait(self): now time.time() if self._queue: earliest self._queue[0] if now - earliest self.interval: time.sleep(self.interval - (now - earliest)) self._queue.append(time.time()) while self._queue and time.time() - self._queue[0] 1.0: self._queue.popleft()虽然简单但足以保证消息发送平稳不触发 Twilio 的限流。真实场景里一个可以正常运行的限速器比看上去更值钱。5.3 凭据安全你的 Auth Token 就是白花花的钱Auth Token 泄露被滥用的情况比想象中常见。最常见的是把包含 Token 的代码推到 GitHub 公开仓库被爬虫扫到后直接拿去发 spam。Twilio 也为这种异常行为提供了监控发现滥用时会风控账号但代价是你的业务停了。安全做法有这几个.env 文件加入 .gitignore确认没有进版本库生产环境用 Docker Secrets、Kubernetes Secret 或云厂商的密钥管理服务保存定期换 Token。控制台里可以吊销旧 Token 生成新 Token如果怀疑泄露立刻在控制台吊销并重新生成然后去检查账号近期用量。这些不用写代码但是比任何代码功能都重要。5.4 通知系统本身也需要被监控挺讽刺的你用短信系统去保护其他系统的可靠性但短信系统自己的故障常常要等到业务方投诉才发现。我的建议是至少给短信发送暴露三个指标发送成功率发送成功数 / 尝试数送达率delivered / 已发送数平均延迟从 send 调用到 delivered 回调的时长。用日志就能收集这些信息。发送时打一条 INFO 日志记录 sid 和 to回调时再打一条记录 status 和 error_code之后用日志采集工具做聚合统计。还可以加一个兜底如果连续 10 条消息都是 failed就通过其他渠道比如 IM 机器人通知系统负责人。短信通知系统挂了总得有人第一时间知道。从我的实际体验来说短信通知系统做好的关键不在能发短信而在四个字可控、可查。可控指的是限流和成本可查指的是状态回执。Twilio 的 API 把这些能力都暴露得很清晰你在封装模块的时候把上面这些机制一并带进去整个系统就不会在关键时刻掉链子。最后再分享一个操作习惯每次上线短信功能前我习惯先用测试号码把链路完整走一遍——不是只看到 sent而是等 delivered 的回调出现。只有在这个前提下你才敢说它对用户是可靠的。