SMPP 消息快速存储、查询与更新:redis 还是 solr?
结论
不必在 Redis 和 Solr 之间二选一。对于需要计费、审计和状态更新的 SMPP 平台,建议按职责拆分:
- 使用 PostgreSQL、MySQL 等关系型数据库保存权威消息数据。
- Redis 负责队列、限流、短期状态缓存和快速去重。
- 当运营查询较复杂、数据量较大时,再将消息异步同步到 Solr,用于辅助检索。
Redis 可以根据 message ID 快速查询和更新状态,但不应作为计费与审计数据的唯一存储。Solr 适合搜索和聚合,不适合处理高频、要求强一致性的单条消息状态更新。
如果当前系统规模不大,使用“关系型数据库 + 合理索引”通常就能满足需求,没必要一开始便引入 Solr。
为什么不能只用 Redis
如果 message ID 直接作为 Redis key,读取和更新都很方便:
HGETALL smpp:message:smsc-a:123456
HSET smpp:message:smsc-a:123456 status DELIVERED
按完整 key 查询通常很快,HSET 也适合更新单个字段。真正的问题不是查询速度,而是数据可靠性和查询能力。
计费数据需要更可靠的持久化
Redis 支持 RDB、AOF 和复制,但它首先是内存数据结构服务器。系统能否接受故障时的数据丢失窗口,取决于持久化策略、主从切换配置和写入确认方式。
计费和审计系统通常需要回答以下问题:
- 已接受的消息是否可能在故障后消失?
- 状态更新能否形成可追溯的历史记录?
- 能否通过事务保证消息、费用和事件同时落库?
- 数据需要保留数月或数年时,内存成本是否合理?
- 能否方便地按客户、SMSC、时间和状态生成报表?
这些需求更适合关系型数据库,不适合只用 Redis 处理。
Redis 不适合临时增加查询条件
按 key 查询没有问题,但运营人员通常不只按 message ID 查消息,还可能需要:
- 查询某客户昨天提交但尚未送达的消息;
- 按 SMSC、错误码和小时统计失败率;
- 查找某手机号在一段时间内的发送记录;
- 导出某个账期内的全部计费消息;
- 找出长时间停留在
SUBMITTED状态的记录。
如果全部使用 Redis,就需要提前维护大量集合和二级索引。每增加一种查询方式,都可能要增加一套索引结构,还要保证索引和消息数据一致。
为什么 Solr 也不适合作为主存储
Solr 适合全文搜索、过滤、分面统计和运营检索。消息同步到 Solr 后,可以按多个字段组合查询,也方便支撑搜索后台。
但 SMPP 状态处理本质上是一个事务流程:
- 接收消息并生成内部 ID;
- 持久化消息;
- 发送
submit_sm; - 保存
submit_sm_resp返回的 SMSC message ID; - 接收 delivery receipt;
- 根据当前状态和回执内容更新记录;
- 记录计费或状态事件。
这个流程需要唯一约束、事务、条件更新和幂等处理。Solr 的主要用途是检索,不适合充当计费账本或唯一事实来源。Solr 索引损坏或丢失后,可以根据主数据库重建;主数据库中的业务事实则不能依赖 Solr 恢复。
推荐的数据架构
一个实用的最小架构如下:
业务应用
│
▼
SMPP 接入服务
│
├── PostgreSQL/MySQL:消息、状态事件、计费记录
│
├── Redis:待发送队列、限流、短期去重、热点状态
│
└── Solr:可选的运营检索与复杂统计
各组件可以承担以下职责:
| 组件 | 适合承担的职责 |
|---|---|
| PostgreSQL/MySQL | 权威消息记录、计费、审计、状态历史、事务更新 |
| Redis | 发送队列、重试调度、速率限制、临时映射、幂等缓存 |
| Solr | 多条件搜索、全文检索、运营后台查询 |
| 数据仓库或报表库 | 长周期聚合、复杂报表和大批量 CSV 导出 |
5 个 SMSC、每个约 16 个 bind,只能反映连接数量,不能直接用来推算存储压力。选型主要取决于峰值 TPS、消息保留周期、回执比例、报表范围和允许的数据丢失窗口。
消息表的设计
不要把 SMSC 返回的 message ID 直接作为全局主键。不同 SMSC 可能生成相同的 ID,而且在收到 submit_sm_resp 之前,系统还拿不到这个 ID。
更合适的方式是生成内部消息 ID,例如 UUID,再将 SMSC ID 保存为外部关联字段。
CREATE TABLE smpp_message (
id UUID PRIMARY KEY,
client_id VARCHAR(64) NOT NULL,
smsc_id VARCHAR(64) NOT NULL,
source_addr VARCHAR(32) NOT NULL,
destination_addr VARCHAR(32) NOT NULL,
payload TEXT NOT NULL,
status VARCHAR(32) NOT NULL,
smsc_message_id VARCHAR(128),
submit_attempts INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
submitted_at TIMESTAMPTZ,
final_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
version INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX uq_smpp_message_smsc_ref
ON smpp_message (smsc_id, smsc_message_id)
WHERE smsc_message_id IS NOT NULL;
CREATE INDEX idx_smpp_message_client_created
ON smpp_message (client_id, created_at);
CREATE INDEX idx_smpp_message_status_updated
ON smpp_message (status, updated_at);
是否保存完整短信内容,需要结合隐私、合规和业务要求决定。审计并不一定要求永久保存明文内容,也可以采用加密、脱敏或单独设置保留期限。
不要用当前状态代替状态历史
如果每次收到回执都直接覆盖 status,最终只能看到当前结果,无法追溯状态的变化过程。建议另建一张表,保存不可变的状态事件:
CREATE TABLE smpp_message_event (
id BIGSERIAL PRIMARY KEY,
message_id UUID NOT NULL REFERENCES smpp_message(id),
event_type VARCHAR(32) NOT NULL,
status VARCHAR(32),
raw_status VARCHAR(64),
error_code VARCHAR(64),
received_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
raw_receipt TEXT
);
CREATE INDEX idx_smpp_event_message_time
ON smpp_message_event (message_id, received_at);
smpp_message 保存当前快照,便于快速查询;smpp_message_event 保存完整历史,供审计和排障使用。计费记录最好也使用独立、不可随意覆盖的账本结构。
状态更新需要处理幂等和乱序
SMPP delivery receipt 可能重复到达,也可能不按预期顺序到达。因此,不能简单地把最后收到的状态当作最新状态。
可以通过条件更新限制状态迁移:
UPDATE smpp_message
SET status = 'DELIVERED',
final_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
version = version + 1
WHERE smsc_id = :smsc_id
AND smsc_message_id = :smsc_message_id
AND status IN ('ACCEPTED', 'SUBMITTED', 'UNKNOWN');
如果更新行数为 0,需要判断具体原因:
- 回执重复;
- 消息尚未保存 SMSC message ID;
- message ID 格式不一致;
- 状态已经进入终态;
- 回执早于
submit_sm_resp的关联写入; - SMSC 返回了无法识别的 ID。
建议先将原始回执保存为事件,再尝试更新当前状态。即使暂时无法匹配消息,后续也可以重新关联,不会直接丢失回执。
Redis 应该怎样使用
Redis 很适合保存短期关联映射:
def cache_smsc_mapping(redis_client, smsc_id, smsc_message_id, internal_id):
key = f"smpp:map:{smsc_id}:{smsc_message_id}"
redis_client.set(key, str(internal_id), ex=7 * 24 * 3600)
def find_internal_id(redis_client, smsc_id, smsc_message_id):
key = f"smpp:map:{smsc_id}:{smsc_message_id}"
return redis_client.get(key)
收到回执后,可以先查 Redis;如果缓存未命中,再查询数据库:
def resolve_message_id(db, redis_client, smsc_id, smsc_message_id):
internal_id = find_internal_id(
redis_client,
smsc_id,
smsc_message_id,
)
if internal_id is not None:
return internal_id.decode("utf-8")
row = db.fetch_one(
"""
SELECT id
FROM smpp_message
WHERE smsc_id = %s
AND smsc_message_id = %s
""",
(smsc_id, smsc_message_id),
)
if row is not None:
cache_smsc_mapping(
redis_client,
smsc_id,
smsc_message_id,
row["id"],
)
return str(row["id"])
return None
缓存只负责加速查询,数据库仍是最终依据。即使 Redis 不可用或缓存已经过期,状态处理也应正常继续。
Redis 还适合:
- 按 SMSC 实施 TPS 限流;
- 保存短期幂等键;
- 管理待发送任务和延迟重试任务;
- 缓存路由配置;
- 保存 bind 节点的临时运行状态。
如果使用 Redis 承担队列职责,需要明确消息确认、消费者崩溃恢复、重试次数、死信处理和持久化策略。普通 Pub/Sub 不适合单独承担这项任务,因为消费者离线期间无法自动补收已经发布的消息。可以考虑 Redis Streams,也可以根据吞吐量和可靠性要求选用 RabbitMQ、Kafka 等专门的消息系统。
Solr 应该怎样接入
如果关系型数据库的查询性能能够满足运营需求,可以暂时不使用 Solr。先尝试通过索引、分区表、只读副本和报表任务解决查询问题,这样系统会简单得多。
确实需要 Solr 时,应采用异步同步方式:
数据库事务提交
│
▼
Outbox/CDC 事件
│
▼
索引同步消费者
│
▼
Solr
不要在一次请求中依次写入数据库和 Solr,并假设两次写入一定会同时成功。更可靠的做法是在同一个数据库事务中写入业务记录和 outbox 事件,再由后台消费者更新 Solr。
Solr 文档可以保存用于检索的字段,例如:
{
"id": "7db74057-d399-47ae-9ee4-e238767cd184",
"client_id": "customer-a",
"smsc_id": "smsc-2",
"source_addr": "SERVICE",
"destination_addr": "86138******",
"status": "DELIVERED",
"created_at": "2026-09-01T08:30:00Z",
"final_at": "2026-09-01T08:30:08Z",
"error_code": null
}
Solr 中的数据应被视为可以重建的搜索副本。如果 CSV 报表需要导出大量完整数据,通常更适合从数据库只读副本、数据仓库或离线导出任务生成,以免长时间占用在线事务库和搜索集群。
SMPP message ID 的几个陷阱
ID 不一定全局唯一
关联键至少应包含:
smsc_id + smsc_message_id
如果同一个 SMSC 的不同账号可能生成重叠 ID,还需要把账号或 bind 配置标识加入组合键。
格式可能不一致
部分 SMSC 在 submit_sm_resp 和 delivery receipt 中返回的 ID,可能存在大小写、前导零、十进制与十六进制表示等差异。具体情况取决于运营商的实现,不能假设所有 SMSC 的行为都一样。
应为每个 SMSC 配置明确的 ID 规范化规则,同时保留原始值:
def normalize_message_id(raw_id: str) -> str:
return raw_id.strip().lower()
这段规则只是示例,不能直接套用于所有 SMSC。正式接入前,应使用实际报文进行验证。
一个业务消息可能对应多个 SMPP 消息
长短信拆分后,一个业务消息可能对应多个 segment,每个 segment 都有各自的 SMSC message ID 和状态。因此,数据模型最好区分:
- 业务消息;
- SMPP submit 记录;
- 分片记录;
- delivery receipt 事件;
- 计费记录。
如果不做区分,长短信的部分失败、重试和计费将很难准确记录。
推荐的落地顺序
第一阶段可以采用较简单的方案:
- 使用 PostgreSQL 或 MySQL 保存消息、分片、状态事件和计费记录。
- 为
(smsc_id, smsc_message_id)、客户、状态和时间字段建立索引。 - 使用内部 UUID 贯穿整个发送流程。
- 为提交、回执和重试操作实现幂等处理。
- 根据实际发送速率判断是否需要加入 Redis。
- 运营搜索确实成为瓶颈后,再通过异步方式接入 Solr。
- 报表数据量继续增长时,再引入只读副本、分区表或数据仓库。
如果现在必须选择一种核心存储,应选择关系型数据库,而不是 Redis 或 Solr。Redis 负责提升性能,Solr 负责检索,计费与审计系统则需要一个可靠、可追溯并支持事务的权威数据源。
备注:内容仅供参考。
