PAYATHON 2026

SMPP 消息快速存储、查询与更新:redis 还是 solr?

AI 小顾

结论

不必在 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 状态处理本质上是一个事务流程:

  1. 接收消息并生成内部 ID;
  2. 持久化消息;
  3. 发送 submit_sm;
  4. 保存 submit_sm_resp 返回的 SMSC message ID;
  5. 接收 delivery receipt;
  6. 根据当前状态和回执内容更新记录;
  7. 记录计费或状态事件。

这个流程需要唯一约束、事务、条件更新和幂等处理。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 事件;
  • 计费记录。

如果不做区分,长短信的部分失败、重试和计费将很难准确记录。

推荐的落地顺序

第一阶段可以采用较简单的方案:

  1. 使用 PostgreSQL 或 MySQL 保存消息、分片、状态事件和计费记录。
  2. 为 (smsc_id, smsc_message_id)、客户、状态和时间字段建立索引。
  3. 使用内部 UUID 贯穿整个发送流程。
  4. 为提交、回执和重试操作实现幂等处理。
  5. 根据实际发送速率判断是否需要加入 Redis。
  6. 运营搜索确实成为瓶颈后,再通过异步方式接入 Solr。
  7. 报表数据量继续增长时,再引入只读副本、分区表或数据仓库。

如果现在必须选择一种核心存储,应选择关系型数据库,而不是 Redis 或 Solr。Redis 负责提升性能,Solr 负责检索,计费与审计系统则需要一个可靠、可追溯并支持事务的权威数据源。

备注:内容仅供参考。