风险预警的链路问题
抵押品价值一旦跌破某个水位,系统得及时提醒客户补仓。这是 Collateral 系统的一块硬需求,风险预警。
听起来就是发个通知的事,但实际做起来没那么简单。
最早的做法是什么?计算模块算完之后,直接在代码里调通知服务发消息。计算和通知耦合在一起,就像你炒完菜直接端着锅去敲邻居的门,菜是炒好了,但你和邻居的关系被绑死了。
问题很明显:
- 通知服务挂了,计算也跟着失败。一个不影响计算结果的事情,把整个 Job 拖崩了。
- 没法扩展通知渠道。今天发短信,明天要加邮件,后天要加 App 推送。每加一个渠道就得改计算模块的代码。
- 失败重试很难做。通知没发成功怎么办?在计算代码里写重试逻辑?那重试几次、间隔多久、失败后怎么办,全是计算模块的负担。
直接调用的问题,就是把不相关的事情绑在一起,一荣俱损。
为什么选 SQS
我选 AWS SQS 来解这个问题,原因很直接:
它是托管的,不用自己搭消息队列、不用管高可用、不用运维。金融系统自己运维一个 Kafka 集群的成本不低,SQS 直接省了这块。
SQS 的概念也少,就队列、消息、消费、删除。没有 topic、partition、consumer group 那套复杂模型。对于”发个提醒”这种场景,够用了。
另外它天然支持重试。消息被消费后如果不删,过一会会重新回到队列里。重试逻辑是 SQS 内置的,不用自己写。
队列设计
我先按通知渠道拆了队列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| queues: - name: collateral-alert-sms visibility_timeout: 60 message_retention: 1209600 redrive_policy: max_receive_count: 5 dead_letter_queue: collateral-alert-sms-dlq
- name: collateral-alert-email visibility_timeout: 120 message_retention: 1209600 redrive_policy: max_receive_count: 5 dead_letter_queue: collateral-alert-email-dlq
- name: collateral-alert-push visibility_timeout: 30 message_retention: 1209600 redrive_policy: max_receive_count: 3 dead_letter_queue: collateral-alert-push-dlq
|
每个渠道一个队列,好处是互不阻塞。短信服务慢了不会影响邮件的发送。如果以后要加新渠道,新建一个队列就行,生产端和消费端都不用改。
每个队列还配了死信队列(DLQ)。消息重试到上限还没成功,自动转到 DLQ 里,不会一直堵在主队列。运维同学盯 DLQ 就行,有问题人工介入处理。
生产端:怎么发消息
计算模块算出需要预警的结果后,往队列里丢消息。这一步要做的就是构造消息体、发送,仅此而已。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| import boto3 import json
sqs = boto3.client("sqs", region_name="ap-southeast-1")
class AlertProducer: def __init__(self): self.queue_urls = { "sms": "https://sqs.ap-southeast-1.amazonaws.com/xxx/collateral-alert-sms", "email": "https://sqs.ap-southeast-1.amazonaws.com/xxx/collateral-alert-email", "push": "https://sqs.ap-southeast-1.amazonaws.com/xxx/collateral-alert-push", }
def send_alert(self, alert_type, alert_payload): """根据预警类型发到对应队列""" queue_url = self.queue_urls[alert_type]
message = { "alert_id": alert_payload["alert_id"], "client_ref": alert_payload["client_ref"], "alert_level": alert_payload["alert_level"], "trigger_date": alert_payload["trigger_date"], "template_id": alert_payload["template_id"], }
sqs.send_message( QueueUrl=queue_url, MessageBody=json.dumps(message), MessageAttributes={ "alert_level": {"DataType": "String", "StringValue": alert_payload["alert_level"]}, }, )
|
这里有个细节:消息体里不包含完整的业务数据,只有一个 alert_id 和渲染模板需要的元数据。具体的客户信息、抵押品明细,由消费端按需从数据库查。这样消息体小、传输快,也降低了敏感信息在队列里流转的风险。
消费端:怎么处理消息
消费端是一个独立的 Python 服务,长期轮询队列:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| class AlertConsumer: def __init__(self, queue_url, channel): self.queue_url = queue_url self.channel = channel
def poll(self): while True: messages = sqs.receive_message( QueueUrl=self.queue_url, MaxNumberOfMessages=10, WaitTimeSeconds=20, )
for msg in messages.get("Messages", []): try: self._process(msg) sqs.delete_message( QueueUrl=self.queue_url, ReceiptHandle=msg["ReceiptHandle"], ) except Exception as e: logger.error(f"处理失败: {e}, msg_id={msg['MessageId']}")
def _process(self, msg): body = json.loads(msg["Body"]) detail = self._fetch_alert_detail(body["alert_id"]) rendered = self.template.render(body["template_id"], detail) self.sender.send(self.channel, body["client_ref"], rendered)
|
关键设计:只有处理成功才删消息。如果 _process 抛异常,消息不会被删除,等 visibility_timeout 过后会重新回到队列,被另一个消费者拉取。这就是 SQS 内置的重试机制。
长轮询(WaitTimeSeconds=20)也值得一提。没有消息时,SQS 会 hold 住请求最多 20 秒,减少空轮询的请求量,省钱也省资源。
死信队列和告警
消息重试 5 次都失败,会进 DLQ。我单独写了一个 DLQ 监控脚本,一旦 DLQ 里有消息,立刻触发告警通知运维:
1 2 3 4 5 6 7 8 9 10 11 12 13
| def check_dlq(dlq_url): result = sqs.get_queue_attributes( QueueUrl=dlq_url, AttributeNames=["ApproximateNumberOfMessages"], ) count = int(result["Attributes"]["ApproximateNumberOfMessages"]) if count > 0: alert_service.notify( level="critical", message=f"SQS DLQ {dlq_url} 有 {count} 条积压消息", )
|
这样,任何一条预警消息都不会悄无声息地丢掉。要么成功发出去,要么进 DLQ 被人工处理。
收益
上 SQS 之后最大的变化是:计算模块和通知模块彻底解耦了。
- 通知服务挂了,计算照样跑完,消息在队列里等着。
- 通知服务恢复后,积压的消息自动被消费,不用人工干预。
- 加新渠道只加新队列,计算模块一行代码都不用改。
消息队列真正的价值是解耦。生产端不用关心消费端活没活着,消费端不用关心生产端发了几条。各干各的,通过队列这个缓冲层协作。
回头看,风险预警这种链路天然适合用消息队列。生产端(计算)和消费端(通知)的可靠性要求不同、速度不同、扩展方式也不同。硬拧在一起只会互相拖累,拆开反而都舒服了。