我工作这几年(三)-- 实现短信平台
快到了07年底的时候,新的任务过来了,需要基于公司的短信协议栈来实现一个短信业务平台,目标是要尽可能高地实现短信平台的性能,以方便后面的业务扩展。
根据最初的设计,我们想实现如下的方案:
图1
其中AA、Portal,是已有的模块,为了实现短信功能,需要新增MsgAgent、eMTS这两个模块,其中MsgAgent是企业内的一个短信代理模块,一方面给企业内的AA、Portal等模块提供短信发送功能,MsgAgent通过短信协议栈直接将短信发送给运营商的短信网关或短信中心,另一方面,Hosting中心的eMTS模块从短信网关或短信中心接收短信,再根据接收方的号码找到对应的企业,再根据企业找到对应的MsgAgent,转发给企业内的MsgAgent来处理。
l CMPP 中国移动
l SMGP 中国电信
l SGIP 中国联通
l CNGP 中国网通
l SMPP 国际标准 3.3, 3.4
MsgAgent、eMTS都以SP的方式接入到短信网关,在特定的场景下也可以通过SMPP接入到短信中心,主要几种短信协议的区别,后面再专门写个文章介绍下,一方面把几年前的知识点再回顾下,另一方面,给有需要的人参考。
这个短信平台主要由另一个同事与我共同实现,他负责实现eMTS,我负责实现MsgAgent。
万事开头难,首先我们对短信协议不了解;另外,由于本次要尽可能高地提升短信并发处理能力,肯定要使用多线程,但之前一直搞Portal,多线程用不到,所以这部分知识点之前没有涉及到,需要重点熟悉下;再之外,整体我们还要再重点设计下,以便后面的扩展。
好在我们有两人,之前已经一起配合过好长时间了,再配合也比较顺手。他工作经验比我丰富,多线程这部分由他负责。07年底那会,JDK 1.5已经出来一段时间了,其中引入的一个比较重要及简单易用的特性就是对多线程并发的简化与支持,在java.util.concurrent这个包下,我们主要使用了BlockingQueue这个类,顾名思义,就是阻塞队列。该类主要提供了两个方法offer()和take(),前者将一个对象放到队列中,如果队列已经满了,就直接返回false;后者从head取一个对象,如果没有对象,就等待直到有可取的对象。
再从头来分析一下我们的业务场景,第三方模块通过HTTP请求来发送短信,MsgAgent收到请求后,对应的HTTP请求处理线程直接从HTTP请求中取出关键字,经过基本的字段检查及鉴权后,将基本字段封装成一个Bean,然后将Bean直接插入到BlockingQueue的尾部,由相应的处理线程来进行进一步的处理。如果当前系统已经超负荷,即BlockingQueue队列已满,则offer()会直接返回false,则Http响应直接返回短信发送失败。处理线程通过调用BlockingQueue的take(),在队列空的情况下,会处于wait状态,当队列中有新元素插入,则多个处理线程中的某一个会被唤醒处理消息。处理线程将消息传递到短信协议栈层进一步地处理。再来看eMTS的业务场景,从短信协议栈收到短信后,提取基本字段,封装Bean,插入到消息队列,由专门的处理线程将对象通过SOAP消息发送给某MsgAgent。
从前面的分析可以看到,eMTS、MsgAgent的主要逻辑非常地相似,消息队列实现与消息处理部分的逻辑,通过基类抽取和代码封装,很多代码可以重用。
图2
最主要的逻辑,我总结成了上面的流程,从外面收到http+xml的消息后,消息会被封装成Bean对象插入到消息队列中;消息处理线程会将消息从队列中取出转给短信协议栈进一步处理发送出去;短信协议栈层将短信发送给短信网关后并且短信网关回响应(成功或失败都有响应)后,短信协议栈层会通过回调消息通过一个回调线程通过指定接口抛给应用层进一步地处理,我的实现逻辑是将此响应消息转成一个Bean插入到“短信响应及回执队列”中,由此队列的处理线程进一步处理。
这里有一点要说明一下,如果不说清楚,后面有些逻辑就说不通了。短信都是有短信回执的,短信回执的生成机制是这样的:短信由基站送到接收方的手机后,接收方的手机会生成一条短信回执,该回执再通过基站沿着短信的发送路径原路返回,送到该用户所在的短信中心后,短信中心会将此短信回执再送到MsgAgent对接的短信网关上,短信网关再将此短信回执回送到某SP,此处即发送短信的MsgAgent上。
MsgAgent将短信发送到短信网关上,短信网关会为此短信生成一个唯一的ID,如下是CMPP 2.0规范中SP生成短信id的规则说明,其它协议的规范也类似:
字段名 | 字段字节数 | 字段类型 | 字段描述 |
Msg_Id | 8 | Unsigned Integer | 信息标识,生成算法如下: 采用64位(8字节)的整数: (1) 时间(格式为MMDDHHMMSS,即月日时分秒):bit64~bit39,其中 bit64~bit61:月份的二进制表示; bit60~bit56:日的二进制表示; bit55~bit51:小时的二进制表示; bit50~bit45:分的二进制表示; bit44~bit39:秒的二进制表示; (2) 短信网关代码:bit38~bit17,把短信网关的代码转换为整数填写到该字段中。 (3) 序列号:bit16~bit1,顺序增加,步长为1,循环使用。 各部分如不能填满,左补零,右对齐。 (SP根据请求和应答消息的Sequence_Id一致性就可得到CMPP_Submit消息的Msg_Id) |
短信网关将此短信ID送给短信中心后,短信中心及其它网元是不是一直会使用此短信ID,目前没有确认过。短信网关接受SP的短信请求后,就会给SP回一条响应消息。响应消息中会带上此短信的唯一ID,后续短信回执回来后,也会带上此ID来标识是哪个短信的回执。
再回到MsgAgent的处理逻辑,MsgAgent响应第三方的http+xml响应后,返回的响应会带上一个ID,暂且叫它ID1。MsgAgent将短信送给短信协议栈后,短信协议栈是异步返回响应的,异常返回的响应是网关生成的ID,暂且叫它ID2。给第三方的Http响应返回时,对应的短信请求可能还在消息队列中,因此没法使用短信网关生成的ID2直接返回,所以内部必须要保留ID1与ID2的映射关系。
短信的有效期一般是48小时,虽然90%以上的短信的回执会在10分钟以内返回,仍有一部分可能要很久才会回来,这时应该如何处理?如果短时间内短信量很大的话,内存内要保存的内容有限,超出阀值的部分是不能直接丢弃的,又该如何处理?另外,短信在内存中我们只能保留一段时间,过期的需要保存到DB中,有没有现成的机制?通过一段时间的研究,我们发现ehcache非常符合我们的需求。上述的问题,使用ehcache都有现成的方案可以解决。Ehcache的详细介绍可以参考其官网:http://ehcache.org/。我使用ehcache还是几年的事情了,相信其功能已经有了进一步的扩展。
ehcache.xml的实现:
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="ehcache.xsd">
<!-- <diskStore path="java.io.tmpdir" />-->
<diskStore path="java.io.tmpdir" />
<defaultCache eternal="true" maxElementsInMemory="100"
maxElementsOnDisk="3000" overflowToDisk="true"
diskSpoolBufferSizeMB="30" diskPersistent="true"
memoryStoreEvictionPolicy="FIFO" />
<cache name="smsCache" eternal="false" maxElementsInMemory="100000"
overflowToDisk="false" timeToLiveSeconds="600"
memoryStoreEvictionPolicy="FIFO">
<cacheEventListenerFactory
class="com.zhao3546.msgagent.cache.smscache.cachelistener.EhcacheEventListenerFactory" />
</cache>
</ehcache>
<!-- <defaultCache-->
<!-- maxElementsInMemory="10000"-->
<!-- eternal="false"-->
<!-- timeToIdleSeconds="120"-->
<!-- timeToLiveSeconds="120"-->
<!-- overflowToDisk="true"-->
<!-- diskSpoolBufferSizeMB="30"-->
<!-- maxElementsOnDisk="10000000"-->
<!-- diskPersistent="false"-->
<!-- diskExpiryThreadIntervalSeconds="120"-->
<!-- memoryStoreEvictionPolicy="LRU"-->
<!-- />-->
SmsCacheFactory的实现:
package com.zhao3546.msgagent.cache.smscache;
...
/**
* Sms 缓存,用于保存从外部接收到的短信和通过Msg Agent发送出去的短信
*/
public final class SmsCacheFactory
{
/**
* ehCache的配置文件的文件名
*/
private static final String EHCACHE_CONFIG_FILE_NAME = "ehcache.xml";
/**
* SMS Cache的名称
*/
public static final String SMS_CACHE_NAME = "smsCache";
...
/**
* 短信缓存
*/
private static ICache smsCache;
...
/**
* 缓存的容器
*/
private static Map<String, ICache> map = new HashMap<String, ICache>();
private SmsCacheFactory()
{
}
/**
* 初始化ehCache缓存管理器,并从中取得SMS Cache
* 用于缓存通过 Msg Agent 发送出去的短信和 Msg Agent 接收到的短信
*/
public static void init()
{
...
File configFile = new File(configFilePath);
// ehCache的缓存管理器
CacheManager manager = null;
if (configFile.exists())
{
if (log.isDebugEnabled())
{
log.debug("The full path of Sms cache config file["
+ configFilePath + "]");
}
try
{
manager = new CacheManager(new FileInputStream(configFile));
}
catch (CacheException e)
{
log.error("Fail to load ehcache.xml to config sms cache.", e);
manager = new CacheManager();
}
catch (FileNotFoundException e)
{
log.error("Fail to load ehcache.xml to config sms cache.", e);
manager = new CacheManager();
}
}
else
{
log.warn("The Sms cache config file does not exist,whose path["
+ configFilePath + "]");
manager = new CacheManager();
}
// 根据 ehcache.xml 文件中的Cache配置,获取所有的Cache
for (String cacheName : manager.getCacheNames())
{
if (log.isDebugEnabled())
{
log.debug("Success to init,cacheName[" + cacheName + "]");
}
ICache smsCache = new EhCacheImpl(manager.getCache(cacheName));
map.put(cacheName, smsCache);
}
smsCache = map.get(SMS_CACHE_NAME);
..
}
/**
* 根据缓存名称来获取缓存
*
* @param cacheName :缓存名称
* @return
*/
public static ICache getCache(String cacheName)
{
return map.get(cacheName);
}
/**
* 获取短信缓存
*
* @return
*/
public static ICache getSmsCache()
{
return smsCache;
}
...
}
我们使用ehcache主要使用了如下特性:
1、超过内存阀值的部分,自动序列化到磁盘中;
2、实现了CacheEventListener,处理notifyElementEvicted()、notifyElementExpired()等各种事件,通过一个线程定时对ehcache中的所有元素进行扫描,将过期的元素自动保存到DB中;
3、ehcache本身的使用类似一个HashMap;
短信发送出去后,余下的主要就是将短信回执与短信进行匹配,并将短信回执通知给发送者,这部分逻辑相对比较清晰。根据短信回执中的ID2,先优先在ehcache中找对应的MsgAgent为其生成的短信ID1,如果可以找到,再根据短信ID1在内存找对应的短信;如果找不到,则直接根据ID2在DB中查找。
短信平台上线后,在现网运行了一段时间出现了OutOfMemory问题,也是我工作这么久遇到的第一个非常难搞的问题,这个问题详细定位和解决过程下次再说吧。