RocketMQ基于Docker环境下的部署及使用
欢迎大家访问handsomecui的博客,转载请注明地址https://www.cnblogs.com/handsomecui,欢迎加入技术群讨论:778757421
一、部署
- 目录结构构建:
- docker-compose.yml
version: '3.5' services: rmqnamesrv: image: foxiswho/rocketmq:server container_name: rmqnamesrv ports: - 9876:9876 volumes: - ./data/logs:/opt/logs - ./data/store:/opt/store networks: rmq: aliases: - rmqnamesrv rmqbroker: image: foxiswho/rocketmq:broker container_name: rmqbroker ports: - 10909:10909 - 10911:10911 volumes: - ./data/logs:/opt/logs - ./data/store:/opt/store - ./data/brokerconf/broker.conf:/etc/rocketmq/broker.conf environment: NAMESRV_ADDR: "rmqnamesrv:9876" JAVA_OPTS: " -Duser.home=/opt" JAVA_OPT_EXT: "-server -Xms128m -Xmx128m -Xmn128m" command: mqbroker -c /etc/rocketmq/broker.conf depends_on: - rmqnamesrv networks: rmq: aliases: - rmqbroker rmqconsole: image: styletang/rocketmq-console-ng container_name: rmqconsole ports: - 10801:8080 environment: JAVA_OPTS: "-Drocketmq.namesrv.addr=rmqnamesrv:9876 -Dcom.rocketmq.sendMessageWithVIPChannel=false" depends_on: - rmqnamesrv networks: rmq: aliases: - rmqconsole networks: rmq: name: rmq driver: bridge
- data/brokerconf/broker.conf
version: '3.5' services: rmqnamesrv: image: foxiswho/rocketmq:server container_name: rmqnamesrv ports: - 9876:9876 volumes: - ./data/logs:/opt/logs - ./data/store:/opt/store networks: rmq: aliases: - rmqnamesrv rmqbroker: image: foxiswho/rocketmq:broker container_name: rmqbroker ports: - 10909:10909 - 10911:10911 volumes: - ./data/logs:/opt/logs - ./data/store:/opt/store - ./data/brokerconf/broker.conf:/etc/rocketmq/broker.conf environment: NAMESRV_ADDR: "rmqnamesrv:9876" JAVA_OPTS: " -Duser.home=/opt" JAVA_OPT_EXT: "-server -Xms128m -Xmx128m -Xmn128m" command: mqbroker -c /etc/rocketmq/broker.conf depends_on: - rmqnamesrv networks: rmq: aliases: - rmqbroker rmqconsole: image: styletang/rocketmq-console-ng container_name: rmqconsole ports: - 10801:8080 environment: JAVA_OPTS: "-Drocketmq.namesrv.addr=rmqnamesrv:9876 -Dcom.rocketmq.sendMessageWithVIPChannel=false" depends_on: - rmqnamesrv networks: rmq: aliases: - rmqconsole networks: rmq: name: rmq driver: bridge [root@CENTOS7 rocketmq]# cat data/brokerconf/broker.conf # 是否允许 Broker 自动创建订阅组,建议线下开启,线上关闭 autoCreateSubscriptionGroup=true # Broker 对外服务的监听端口 listenPort=10911 # 删除文件时间点,默认凌晨4点 deleteWhen=04 # 文件保留时间,默认48小时 fileReservedTime=120 # commitLog 每个文件的大小默认1G mapedFileSizeCommitLog=1073741824 # ConsumeQueue 每个文件默认存 30W 条,根据业务情况调整 mapedFileSizeConsumeQueue=300000 # destroyMapedFileIntervalForcibly=120000 # redeleteHangedFileInterval=120000 # 检测物理文件磁盘空间 diskMaxUsedSpaceRatio=88 # 存储路径 # storePathRootDir=/home/hbc/rocketmq-all-4.1.0-incubating/store # commitLog 存储路径 # storePathCommitLog=/home/hbc/rocketmq-all-4.1.0-incubating/store/commitlog # 消费队列存储 # storePathConsumeQueue=/home/hbc/rocketmq-all-4.1.0-incubating/store/consumequeue # 消息索引存储路径 # storePathIndex=/home/hbc/rocketmq-all-4.1.0-incubating/store/index # checkpoint 文件存储路径 # storeCheckpoint=/home/hbc/rocketmq-all-4.1.0-incubating/store/checkpoint # abort 文件存储路径 # abortFile=/home/hbc/rocketmq-all-4.1.0-incubating/store/abort # 限制的消息大小 maxMessageSize=65536 # flushCommitLogLeastPages=4 # flushConsumeQueueLeastPages=2 # flushCommitLogThoroughInterval=10000 # flushConsumeQueueThoroughInterval=60000 # Broker 的角色 # - ASYNC_MASTER 异步复制Master # - SYNC_MASTER 同步双写Master # - SLAVE brokerRole=ASYNC_MASTER # 刷盘方式 # - ASYNC_FLUSH 异步刷盘 # - SYNC_FLUSH 同步刷盘 flushDiskType=ASYNC_FLUSH # 发消息线程池数量 # sendMessageThreadPoolNums=128 # 拉消息线程池数量 # pullMessageThreadPoolNums=128 brokerIP1=xx:xx:xx:xx #部署机内网ip
- rocketmq目录启动:docker-compose up -d
- 访问: http://your_ip:10801
二、客户端使用
- 依赖
<dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.5.2</version> </dependency>
- 生产者
import org.apache.rocketmq.client.producer.DefaultMQProducer; import org.apache.rocketmq.client.producer.SendResult; import org.apache.rocketmq.common.message.Message; import org.apache.rocketmq.remoting.common.RemotingHelper; public class Producer { public static void main(String[] args) throws Exception { //Instantiate with a producer group name. DefaultMQProducer producer = new DefaultMQProducer("agent-server"); // Specify name server addresses. producer.setNamesrvAddr("your_ip:9876"); //Launch the instance. producer.start(); for (int i = 0; i < 1; i++) { //Create a message instance, specifying topic, tag and message body. Message msg = new Message("TopicTest" /* Topic */, "TagA" /* Tag */, ("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET) /* Message body */ ); //Call send message to deliver message to one of brokers. SendResult sendResult = producer.send(msg); System.out.printf("%s%n", sendResult); } //Shut down once the producer instance is not longer in use. producer.shutdown(); } }
- 消费者
import java.util.List; import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer; import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext; import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus; import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently; import org.apache.rocketmq.client.exception.MQClientException; import org.apache.rocketmq.common.message.MessageExt; public class Consumer { public static void main(String[] args) throws InterruptedException, MQClientException { // Instantiate with specified consumer group name. DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("agent-server"); // Specify name server addresses. consumer.setNamesrvAddr("your_ip:9876"); // Subscribe one more topics to consume. *表示订阅所有tags consumer.subscribe("TopicTest", "*"); // Register callback to execute on arrival of messages fetched from brokers. consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs); return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); //Launch the consumer instance. consumer.start(); System.out.printf("Consumer Started.%n"); } }
三、问题记录
- 发消息一直弹出出现closeChannel: close the connection to remote address[] result: true:
原因: 没配置brokerIP1和brokerIP2时,broker会根据当前网卡选择一个IP监听 解决:打开data/brokerconf/broker.conf 指定当前broker监听的IP brokerIP1=192.168.29.18
四、 常用命令
1 创建/修改Topic: updateTopic 2 删除:deleteTopic 3 创建/修改订阅组:updateSubGroup 4 删除订阅组:deleteSubGroup 5 更新Broker配置:updateBrokerConfig 6 更新Topic读写权限:updateTopicPerm 7 查询topic路由信息:TopicRoute 8 查看Topic列表信息:TopicList 9 查看Topic统计信息:TopicStatus 10 根据时间查询消息:printMsg 11 根据消息id查询消息:queryMsgById 12 查看集群消息:clusterList
sh bin/mqadmin TopicStatus -n localhost:9876 -t handsomecui.local
五、基本原理
- NameServer:整个集群的注册中心和配置中心,管理集群的元数据。包括 Topic 信息和路由信息、Producer 和 Consumer 的客户端注册信息、Broker 的注册信息。
- Broker:负责接收消息的生产和消费请求,并进行消息的持久化和消息的读取。
- Producer:负责生产消息。
- Consumer:负责消费消息。
在实际生产和消费消息的过程中,NameServer 为生产者和消费者提供 Meta 数据,以确定消息该发往哪个 Broker 或者该从哪个 Broker 拉取消息。
有了 Meta 数据后,生产者和消费者就可以直接和 Broker 交互了。这种点对点的交互方式最大限度降低了消息传递的中间环节,缩短了链路耗时。
1 2 3 4 | 1 . Producer:生产者,负责消息的生产和发送。与 NameServer 集群的一个节点建立长连接,定期从 NameServer 获取 其订阅的 Topic 的路由信息,然后向 Topic 所在的 broker 发送消息。 2 . Consumer:消费者,负责消息的拉取和消费。Consumer 与 NameServer 集群的某个节点建立长连接,然后从 NameServer 上获取可以消费的 Topic 中某个 MessageQueue 所在 Broker 的路由信息,<br>然后与其建立长连接,从而不断的拉取消息进行消费(同一个ConsumerGroup下的所有Consumer消费的内容合起来才是所订阅的Topic内容的整体,从而可以达到负载均衡的目的)。 3 . NameServer:整个消息队列的状态服务器,集群的各个组件通过它来了解全局的信息,各个角色的机器会定期向 NameServer 上报自己的状态,如果超时不上报,NameServer 会认为某个机器出故障不可用,<br>其他组件会把这个机器从可用列表中移除。NamerServer 可以部署多个,相互之间独立,其他角色同时向多个机器上报状态信息,从而达到热备份的目的。 4 . Broker是RocketMQ的核心,它负责接收来自Producer发过来的消息、处理Consumer的消费消息的请求、消息的持久化存储、消息的HA机制以及消息在服务端的过滤。 |
六、网络模型
- RocketMQ 使用 Netty 框架实现高性能的网络传输。
- RocketMQ 的 Broker 端基于 Netty 实现了主从 Reactor 模型。架构如下:
-
具体流程: 1. eventLoopGroupBoss 作为 acceptor 负责接收客户端的连接请求 2. eventLoopGroupSelector 负责 NIO 的读写操作 3. NettyServerHandler 读取 IO 数据,并对消息头进行解析 4. disatch 过程根据注册的消息 code 和 processsor 把不同的事件分发给不同的线程。由 processTable 维护(类型为 HashMap)
七、实现原理
- 消息的生产
RocketMQ 支持三种消息发送方式:同步发送、异步发送和 One-Way 发送。One-Way 发送时客户端无法确定服务端消息是否投递成功,因此是不可靠的发送方式。 * 同步发送:注册 ResponseFuture 到 responseTable,发送 Request 请求,并同步等待 Response 返回。 * 异步发送:注册 ResponseFuture 到 responseTable,发送 Request 请求,不需要同步等待 Response 返回,当 Response 返回后会调用注册的
Callback 方法,从而异步获取发送的结果。 * One-Way:发送 Request 请求,不需要等待 Response 返回,不需要触发 Callback 方法回调。1. 客户端 API 调 DefaultMQProducer 的 send 方法进行消息的发送。 2. makeSureStateOk 检查客户端的发送服务是否 ok。RocketMQ 客户端维护了一个单例的 MQClientInstance,
可通过 start 和 shutdown 来管理相关的网络服务。 3. tryToFindTopicPublishInfo 用来获取 Topic 的 Meta 信息,主要是可选的 MessageQueue 列表。 4. selectOneMessageQueue 根据当前的故障容错机制,路由到一个特定的 MessageQueue。 5. sendKernelImpl 的核心方法是调用 NettyRemotingClient 的 sendMessage 方法,该方法中会根据用户选择的发送策略进行区别处理,
时序图中只体现了同步发送的方式。 6. invokeSync 通过调用 Netty 的 channel.writeAndFlush 把消息的字节流发送到 TCP 的 Socket 缓冲区,至此客户端消息完成发送。 -
消息的接收
1. Broker 通过 Netty 接收 RequestCode 为 SEND_MESSAGE 的请求,并把该请求交给 SendMessageProcessor 进行处理。 2. SendMessageProcessor 先解析出 SEND_MESSAGE 报文中的消息头信息(Topic、queueId、producerGroup 等),并调用存储层进行处理。 3. putMessage 中判断当前是否满足写入条件:Broker 状态为 running;Broker 为 master 节点;磁盘状态可写(磁盘满则无法写入);
Topic 长度未超限;消息属性长度未超限;pageCache 未处于繁忙状态(pageCachebusy 的依据是 putMessage 写入 mmap 的耗时,
如果耗时超过 1s,说明由于缺页导致页加载慢,此时认定 pageCache 繁忙,拒绝写入)。 4. 从 MappedFileQueue 中选择已经预热过的 MappedFile。 5. AppendMessageCallback 中执行消息的操作 doAppend,直接对 mmap 后的文件的 bytbuffer 进行写入操作。
八、优化
- 自旋锁减少上下文切换
RocketMQ 的 CommitLog 为了避免并发写入,使用一个 PutMessageLock。PutMessageLock 有
2
个实现版本:PutMessageReentrantLock 和 PutMessageSpinLock。
PutMessageReentrantLock 是基于 java 的同步等待唤醒机制;PutMessageSpinLock 使用 Java 的 CAS 原语,通过自旋设值实现上锁和解锁。RocketMQ 默认使用 <br>PutMessageSpinLock 以提高高并发写入时候的上锁解锁效率,并减少线程上下文切换次数。
- MappedFile 预热和零拷贝机制
1234567891011121314151617181920212223242526272829
RocketMQ 消息写入对延时敏感,为了避免在写入消息时,CommitLog 文件尚未打开或者文件尚未加载到内存引起的 load 的开销,RocketMQ 实现了文件预热机制。
Linux 系统在写数据时候不会直接把数据写到磁盘上,而是写到磁盘对应的 PageCache 中,并把该页标记为脏页。当脏页累计到一定程度或者一定时间后再把数据 flush 到磁盘(当然在此期间如果系统掉电,<br>会导致脏页数据丢失)。RocketMQ 实现文件预热的关键代码如下:
public
void
warmMappedFile(FlushDiskType type,
int
pages) {
ByteBuffer byteBuffer =
this
.mappedByteBuffer.slice();
int
flush =
0
;
long
time = System.currentTimeMillis();
for
(
int
i =
0
, j =
0
; i <
this
.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
byteBuffer.put(i, (
byte
)
0
);
// force flush when flush disk type is sync
if
(type == FlushDiskType.SYNC_FLUSH) {
if
((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
flush = i;
mappedByteBuffer.force();
}
}
...
}
// force flush when prepare load finished
if
(type == FlushDiskType.SYNC_FLUSH) {
log.info(
"mapped file warm-up done, force to disk, mappedFile={}, costTime={}"
,
this
.getFileName(), System.currentTimeMillis() - beginTime);
mappedByteBuffer.force();
}
this
.mlock();
}
代码分析
1
. 对文件进行 mmap 映射。
2
. 对整个文件每隔一个 PAGE_SIZE 写入一个字节,如果是同步刷盘,每写入一个字节进行一次强制的刷盘。
3
. 调用 libc 的 mlock 函数,对文件所在的内存区域进行锁定。(系统调用 mlock 家族允许程序在物理内存上锁住它的部分或全部地址空间。<br> 这将阻止 Linux 将这个内存页调度到交换空间(swap space),即使该程序已有一段时间没有访问这段空间)。
- 同步和异步刷盘
12345678910111213141516171819
RocketMQ 提供了同步刷盘和异步刷盘两种机制。默认使用异步刷盘机制。
当 CommitLog 在 putMessage() 中收到 MappedFile 成功追加消息到内存的结果后,便会调用 handleDiskFlush() 方法进行刷盘,将消息存储到文件中。<br>handleDiskFlush() 便会根据两种刷盘策略,调用不同的刷盘服务。
抽象类 FlushCommitLogService 负责进行刷盘操作,该抽象类有
3
中实现:
* GroupCommitService:同步刷盘
* FlushRealTimeService:异步刷盘
* CommitRealTimeService:异步刷盘并且开启 TransientStorePool
每个实现类都是一个 ServiceThread 实现类。ServiceThread 可以看做是一个封装了基础功能的后台线程服务。有完整的生命周期管理,支持 start、shutdown、weakup、waitForRunning。
同步刷盘流程
1
. 所有的 flush 操作都由 GroupCommitService 线程进行处理
2
. 当前接收消息的线程封装一个 GroupCommitRequest,并提交给 GroupCommitService 线程,然后当前线程进入一个 CountDownLatch 的等待
3
. 一旦有新任务进来 GroupCommitService 被立即唤醒,并调用 MappedFile.flush 进行刷盘。底层是调用 mappedByteBuffer.force ()
4
. flush 完成后唤醒等待中的接收消息线程。从而完成同步刷盘流程
异步刷盘流程
1
. RocketMQ 每隔 200ms 进行一次 flush 操作(把数据持久化到磁盘)
2
. 当有新的消息写入时候会主动唤醒 flush 线程进行刷盘
3
. 当前接收消息线程无须等待 flush 的结果。
- 关闭偏向锁
123
在 RocketMQ 的性能测试中,发现存在大量的 RevokeBias 停顿,偏向锁主要是消除无竞争情况下的同步原语以提高性能,但考虑到 RocketMQ 中该场景比较少,便通过 - XX:-UseBiasedLocking 关闭了偏向锁特性。
在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,<br>使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次 CAS,但偏向锁只有初始化时需要一次 CAS。
偏向锁的使用场景有局限性,只适用于单个线程使用锁的场景,如果有其他线程竞争,则偏向锁会膨胀为轻量级锁。当出现大量 RevokeBias 引起的小停顿时,说明偏向锁意义不大,<br>此时通过 - XX:-UseBiasedLocking 进行优化,因此 RocketMQ 的 JVM 参数中会默认加上 - XX:-UseBiasedLocking。
九、存储
- 消息存储结构
1 2 | * ConsumeQueue 与 CommitLog 不同,采用定长存储结构,如下图所示。为了实现定长存储,ConsumeQueue 存储了消息 Tag 的 Hash Code,在进行 Broker 端消息过滤时,<br>通过比较 Consumer 订阅 Tag 的 HashCode 和存储条目中的 Tag Hash Code 是否一致来决定是否消费消息。 * ReputMessageService 持续地读取 CommitLog 文件并生成 ConsumeQueue。 |
2. 顺序消费与并行消费
1 | 串行消费和并行消费最大的区别在于消费队列中消息的顺序性。顺序消费保证了同一个 Queue 中的消费时的顺序性。RocketMQ 的顺序性依赖于分区锁的实现。消息消费有推拉两种模式,我们这里只考虑推这种模式 |
- 并行消费
123456
1
. 并行消费的实现类为 ConsumeMessageConcurrentlyService。
2
. PullMessageService 内置一个 scheduledExecutorService 线程池,主要负责处理 PullRequest 请求,从 Broker 端拉取最新的消息返回给客户端。拉取到的消息会放入 MessageQueue 对应的 ProcessQueue。
3
. ConsumeMessageConcurrentlyService 把收到的消息封装成一个 ConsumeRequest,投递给内置的 consumeExecutor 独立线程池进行消费。
4
. ConsumeRequest 调用 MessageListener.consumeMessage 执行用户定义的消费逻辑,返回消费状态。
5
. 如果消费状态为 SUCCESS。则删除 ProcessQueue 中的消息,并提交 offset。
6
. 如果消费状态为 RECONSUME。则把消息发送到延时队列进行重试,并对当前失败的消息进行延迟处理。
- 串行消费
1234567
1
. 串行消费的实现类为 ConsumeMessageOrderlyService。
2
. PullMessageService 内置一个 scheduledExecutorService 线程池,主要负责处理 PullRequest 请求,从 Broker 端拉取最新的消息返回给客户端。拉取到的消息会放入 MessageQueue 对应的 ProcessQueue。
3
. ConsumeMessageOrderlyService 把收到的消息封装成一个 ConsumeRequest,投递给内置的 consumeExecutor 独立线程池进行消费。
4
. 消费时首先获取 MessageQueue 对应的 objectLock,保证当前进程内只有一个线程在处理对应的的 MessageQueue, 从 ProcessQueue 的 msgTreeMap 中按 offset 从低到高的顺序取消息,从而保证了消息的顺序性。
5
. ConsumeRequest 调用 MessageListener.consumeMessage 执行用户定义的消费逻辑,返回消费状态。
6
. 如果消费状态为 SUCCESS。则删除 ProcessQueue 中的消息,并提交 offset。
7
. 如果消费状态为 SUSPEND。判断是否达到最大重试次数,如果达到最大重试次数,就把消息投递到死信队列,继续下一条消费;否则消息重试次数 +
1
,在延时一段时间后继续重试。可见,<br>串行消费如果某条消息一直无法消费成功会造成阻塞,严重时会引起消息堆积和关联业务异常。
- Broker 端的 PullMessage 长连接实现
123456789
消息队列中的消息是由业务触发而产生的,如果使用周期性的轮询,不能保证每次都取到消息,且轮询的频率过快或者过慢都会对消息的延时有严重的影响。<br>因此 RockMQ 在 Broker 端使用长连接的方式处理 PullMessage 请求。具体实现流程如下:
1
. PullRequest 请求中有个参数 brokerSuspendMaxTimeMillis,默认值为 15s,控制请求 hold 的时长。
2
. PullMessageProcessor 接收到 Request 后,解析参数,校验 Topic 的 Meta 信息和消费者的订阅关系。对于符合要求的请求,从存储中拉取消息。
3
. 如果拉取消息的结果为 PULL_NOT_FOUND,表示当前 MessageQueue 没有最新消息。
4
. 此时会封装一个 PullRequest 对象,并投递给 PullRequestHoldService 内部线程的 pullRequestTable 中。
5
. PullRequestHoldService 线程会周期性轮询 pullRequestTable,如果有新的消息或者 hold 时间超时 polling time,就会封装 Response 请求发给客户端。
6
. 另外 DefaultMessageStore 中定义了 messageArrivingListener,当产生新的 ConsumeQueue 记录时候,会触发 messageArrivingListener 回调,立即给客户端返回最新的消息。
长连接机制使得 RocketMQ 的网络利用率非常高效,并且最大限度地降低了消息拉取时的等待开销。实现了毫秒级的消息投递。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· Open-Sora 2.0 重磅开源!
· 周边上新:园子的第一款马克杯温暖上架
2016-11-09 xml约束
2015-11-09 Strategic Game(匈牙利算法,最小点覆盖数)
2015-11-09 二分图最大匹配总结
2015-11-09 Card Game Cheater(贪心+二分匹配)