一个系统的实现问题
比较巧妙的解决方案,思想值得学习。
一、需求
现有一个“游戏商人”的需求。点击商人打开商品界面,界面将随机展出6件商品,按照已展示时间长短排序。
每天的每个复数小时(每日0点、2点、4点…24点)将进行一次刷新,移除展示时间最长的商品,替换一个未展示过的随机商品(如果玩家经过了两个刷新时点,那么就刷新两次,替换两个商品)。
玩家可以购买展示中的商品,每种商品有限购次数。
这个系统有很多细节问题。比如:
1、如果玩家很多,如何保证所有玩家的界面信息准点同时刷新?
2、如何对商品进行排序?
3、如何通过随机算法选出刷新的商品?
4、玩家能否绕过展示的限制,买到没有刷出来的商品?
5、玩家能否突破购买次数?如果玩家在商品展示界面挂机,会不会跳过刷新时点?
6、如何保证“边界时间”(即0点)的刷新?
7、如何判断玩家是否首次进入系统?如果玩家首次进入系统,如何刷新所有商品?
其中,“如何刷新商品”是最需要技巧的。我打算把这一部分讲清楚。
二、解决方案
(1)直观的解决方案
1.具体思路
从字面上理解,每天的每个复数小时(每日0点、2点、4点…24点)都要进行一次刷新。因为本项目使用了spring,所以我们可以引入quartz,写一个定时脚本,在每天的复数小时执行一个刷新方法,刷走存在时间最长的一个物品。
那么,现在问题变成了:我们怎么知道哪个物品的存在时间最长呢?
解决方法如下:
在最初进行刷新时(刷新6个商品),我们给每个刷新出来的商品加上时间戳(注意每个商品的时间戳都要唯一,分配时间戳时可能要sleep),封装成json,记录到缓存、数据库中,类似{物品a的Id:111111111,物品b的Id:11111112}。
每次刷新商品时,把这个json取出来解析成HashMap,遍历进行比较时间戳,就能找到存在时间最长的商品的id(时间戳最小)。我们把这个id从HashMap中remove掉,(通过自定义的随机算法)加入一个新的商品,再次封装成json,写回缓存和数据库中。
至此,整个刷新操作就完成了。
2.缺陷
现在,我尝试用上面的思路,举个比较贴近的例子:
在自助餐中,厨师会定时补充一堆蛋糕(定时刷新商品数据)。即使蛋糕完全没人拿(根本没用户使用这些数据),厨师也要准时制作刚出炉的蛋糕,防止客人第一时间拿不到新鲜的食物(防止用户读不到最新的商品数据)。
这个解决方案当然是“诚实”的(即使玩家不打开这个界面,服务端也在默默地更新数据),但是非常笨重。如果客人非常多,厨师就要在很短的时间内一次性制作一大堆蛋糕,肯定会手忙脚乱。
如果当前服存在大量玩家,定时任务就要对大量玩家数据(缓存、数据库中)统一进行操作,每两小时执行一次,会形成负载洪峰。如果此时有别的很耗系统资源的任务(比如跨服活动),就会导致负荷上升,造成性能问题。
除此之外,因为存在大量玩家数据,不可能同时快速刷新完成。如果定时任务在2点整开始,等到全部玩家的数据都刷新完,可能需要1分钟,最后就表现为:一些玩家在2点整完成刷新,某些玩家在2:01才刷新完,这显然违背了需求(同理,不能把定时任务的执行时间提早。如果把定时任务定在1:59执行,那么某些用户就会发现,还未到2:00商品就完成刷新了)。
综上,这种解决方案很简单直观,但是不可行。
(2)我使用的的解决方案
1.具体思路
我们能不能想个办法,不用定时任务的方式实现刷新效果,“欺骗”用户呢?
首先要理解,这个需求的本质是:“到了刷新时点,用户要看到商品刷新”。再进一步解读,应该是:“过了几个刷新时点,用户要看到刷新了几个商品(最多就是6个商品全部刷新)”
那么问题来了,用户要怎么看到这些商品呢?当然是先打开界面。那么,我们能不能在用户打开界面的时候进行刷新操作,最后才进行展示呢?
举个现实点的例子:
厨师不再盲目地定时制作蛋糕(不再定时刷新数据),而是为想要吃蛋糕的客人即时制作。哪个客人想吃蛋糕(打开界面读取数据),厨师就看看时间,如果过了时点,就为客人制作最新的蛋糕(即时刷新)。如果时点未到,厨师就把旧的蛋糕给客人(不需要刷新的数据)。
用户访问界面的时间是分散的,我们可以把“使用1分钟执行定时任务的压力”分散到很长一个时间段中,回避掉性能问题。
那么问题来了,用户打开界面时,我怎么知道要刷新几个商品呢?那就要统计“用户上一次打开界面到这一次打开界面这一段时间内经过了几个刷新时点”。
综上,经过一系列的问题转化,这个需求的最终实现变成了一个算法问题。
2.具体实现
直接上代码:
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 |
// 获取杂货的刷新次数 public int getRefreshTimes(long lastRefreshTime, long now) { long todayZero = DateUtil.getDayStartMillis(now); if (now - lastRefreshTime > 12 * 60 * 60 * 1000) { return 6; } // 存放刷新的时点 Set<Long> timePoints = new HashSet<Long>(); for (int i = 0; i <= 12; i++) { timePoints.add(todayZero + 2 * 60 * 60 * 1000 * i); } // 如果最后刷新时间小于今天0点,就要考虑昨天的刷新时点了,加入到list中 if (lastRefreshTime < todayZero) { long yesterdayZero = DateUtil.getDayStartMillis(lastRefreshTime); for (int i = 0; i <= 12; i++) { timePoints.add(yesterdayZero + 2 * 60 * 60 * 1000 * i); } } int refreshTimes = 0; for (long timePoint : timePoints) { if (lastRefreshTime <= timePoint && timePoint <= now) { refreshTimes++; } } return refreshTimes; } |
这段代码的作用,是判断几个商品需要刷新。这段代码最关键的地方,就是0点这个边界值的判断。
举个例子:
我在2018.1.11晚上23:59打开过界面,在2018.1.12凌晨2:01再次打开界面。期间经过了0点、2点这两个刷新时点,返回值应该为2。为此,需要检测最后的打开时间是否小于今天0点。如果小于,那就说明跨了一天,需要把昨天的刷新时点也纳入计算中。
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 37 38 39 40 41 42 43 44 45 |
// 杂货页面所需要的信息 private CommandResult openZaHuoPage(long playerId) { // 这里永远都不会为空,在manager中会做判断 HuoLangPlayerRecord huoLangPlayerRecord = huoLangPlayerRecordManager .selectHuoLangPlayerRecordByPlayerIdAndType(playerId, zaHuoType); // 获取玩家的活动数据 Map<Integer, Node> recordsMap = huoLangPlayerRecord.getRecordsMap(); long lastRefreshTime = huoLangPlayerRecord.getLastRefreshTime().getTime(); // 统计刷新次数 int refreshTimes = getRefreshTimes(lastRefreshTime, System.currentTimeMillis()); // 需要刷新的情况,超时/玩家第一次进入系统(没有记录数据),需要刷新 if (refreshTimes > 0 || recordsMap.size() == 0) { // 因为玩家刚进入系统,肯定不会超时,refreshTimes为0,就不会刷新了。所以要手动设置refreshTimes为6,让6个道具全部刷新。 if (recordsMap.size() == 0) { refreshTimes = 6; } // 刷新 zaHuoReload(refreshTimes, recordsMap, getZaHuoProbability()); } // 不需要刷新的情况,直接包装数据展示 List<HuoLangRewardDto> dtos = new ArrayList<HuoLangRewardDto>(); for (Integer id : recordsMap.keySet()) { HuoLangReward huoLangReward = huoLangRewardManager.selectHuoLangRewardByIdAndType(id, zaHuoType); Node node = huoLangPlayerRecord.getNodeById(id); int goodsId = huoLangReward.getGoodsId(); Goods goods = goodsManager.get(goodsId); dtos.add(HuoLangRewardDto.createRewardDto(node, goods, huoLangReward)); } Collections.sort(dtos); huoLangPlayerRecord.setLastRefreshTime(new Date()); huoLangPlayerRecordManager.updateHuoLangPlayerRecord(huoLangPlayerRecord); Map<String, Object> resultMap = new HashMap<String, Object>(); resultMap.put("reward", dtos); resultMap.put("nextRefreshTime", getZaHuoNextRefreshTime()); return new CommandResult(ResultCode.SUCC, resultMap); } |
如果不需要刷新,那么直接包装缓存中的数据,返回给用户即可。
三、总结
这个需求算是比较简单,很多人也能想到合适的解决方案,似乎不值得大书特书。
实现这个需求让我学到了很多东西,其中分析需求的本质,转化问题的过程,都是值得学习记录的。