注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

环信FAQ

环信FAQ

集成常见问题及答案
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

半小时到秒级,京东零售定时任务优化怎么做的?

导言:京东零售技术团队通过真实线上案例总结了针对海量数据批处理任务的一些通用优化方法,除了供大家借鉴参考之外,也更希望通过这篇文章呼吁大家在平时开发程序时能够更加注意程序的性能和所消耗的资源,避免在流量突增时给系统带来不必要的压力。 业务背景: 站外广告投放平...
继续阅读 »

导言:京东零售技术团队通过真实线上案例总结了针对海量数据批处理任务的一些通用优化方法,除了供大家借鉴参考之外,也更希望通过这篇文章呼吁大家在平时开发程序时能够更加注意程序的性能和所消耗的资源,避免在流量突增时给系统带来不必要的压力。


业务背景:


站外广告投放平台在做推广管理状态优化重构的时候,引入了四个定时任务。分别是单元时间段更新更新任务,计划时间段更新任务,单元预算撞线恢复任务,计划预算撞线恢复任务。


时间段更新更新任务:


由于单元上可以设置分时段投放,最小粒度是半个小时,每天没半个小时都已可以被广告主设置为可投放或者不可投放,当个广告主修改了,这个时间段,我们可以通过binlog来异步更新这个状态,但是,随着时间的流逝,单元有可能在上半个小时处于可投放状态,来到下半个小时就处于不可投放状态。此时我们的程序是无法感知的,只能通过定时任务,计算每个单元在当前时间段是否需要被更新子状态。计划时间段更新任务类似,也需要半个小时跑一次。


单元预算恢复任务:


当单元的当天日预算被消耗完之后,我们接收到计费的信号后会把该单元的状态更新为预算已用完子状态。但是到第二天凌晨,随着时间的到来,需要把昨天带有预算已用完子状态的单元全部查出来,然后计算当前是否处于撞线状态进行状态更新,此时大部分预算已用完的单元都处于可播放状态,所以这个定时任务只需要一天跑一次,计划类似。


本次以单元和计划的时间段更新为例,因为时间段每半个小时需要跑一次,且数据量多。


数据库:


我们的数据库64分片,一主三从,分片键user_id(用户id)。


定时任务数据源:


我们选取只有站外广告在用的表dsp_show_status作为数据源,这个表总共8500万(85625338)条记录。包含三层物料层级分别是计划,单元,创意通过type字段区分,包含四大媒体(字节,腾讯,百度,快手)和京东播放的物料,可以通过campaignType字段区分。


机器配置和垃圾回收器:


单台机器用的8C16G


-Xms8192m -Xmx8192m -XX:MaxMetaspaceSize=1024m -XX:MetaspaceSize=1024m -XX:MaxDirectMemorySize=1966m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:ParallelGCThreads=8


定时任务处理逻辑


对于单元,


第一步:先查出来出来dsp_show_status 最大主键区间MaxAutoPk和最小区间MinAutoPk。


第二步:根据Ducc里设置的步长,和条件,去查询dsp_show_status表得出数据。其中条件包含层级单元,腾讯渠道(只有腾讯渠道的单元上有分时段投放),不包含投放已过期的数据(已过期的单元肯定不在投放时间段)


伪代码:


startAutoPk=minAutoPk;
while (startAutoPk <= maxAutoPk) {
//每次循环的开始区间
startAutoPkFinal = startAutoPk;
//每次循环的结束区间
endAutoPkFinal = Math.min(startAutoPk + 步长, maxAutoPk);
List showSatusVoList =
showStatusConsumer.betweenListByParam(
startAutoPkL, endAutoPkL,
条件(type=2单元层级,不包含已过期的数据,腾讯渠道))
startAutoPk = endAutoPkFinal + 1;
}

第三步:遍历第二步查询出来showSatusVoList,得到集合单元ids,然后根据集合ids去批量查询单元扩展表,取出单元扩展表里每个单元对应的start_time,end_time,time_range_price_coef字段。进行子状态计算。


计算逻辑伪代码:


1、当前时间

2、end_time <当前时间 ,子状态为 单元投放已结束


3、start_time<当前时间

4、其他,移除单元未开始投放,单元投放已结束,单元不在投放时间段 三个子状态


然后对这批单元按上面的四种情况进行分组,总共分为四组。如果查询来的dsp_show_status表的子状态和算出来的子状态一样则不加入分组,如果不一样则加入相应分组。


最后对这批单元对应的dsp_show_status表里的记录进行四次批量更新。


计划时间段任务处理逻辑类似,但是查询出来的数据源不包含腾讯渠道的,因为腾讯的渠道的时间段在单元上,计划上没有。


任务执行现象:


(一阶段)任务执行时间长且CPU利用率高


按某个pin调试任务,逻辑上落数据没有问题,但是任务时长在五分钟左右。当时是说产品可以接受这个时间子状态更新延迟。


但当不按pin调试进行计划时间段任务更新时,相对好点,十分钟左右,cpu不到50%。


进行单元时间段任务更新时,机器的cpu是这样的:



cpu80%,且执行了半个小时才执行完成。 如果这样,按业务需求,这个批次执行完成就要继续执行下一次了,肯定是不满需求的。


那怎么缩短CPU利用率,缩短任务执行时间呢?听我慢慢讲解。


(二阶段)分析数据源,调大步长缩短任务运行时间


上面这个情况肯定满足不了业务需求的。


第一感觉优化的方向应该往着数据分布上想,于是去分析dsp_show_status表里的数据,发现表里数据稀疏主要是因为两个点。


(1)程序问题 这个表里不仅存在站外的数据,还因为某些程序问题无意落了站内的数据。我们查询数据的时候卡了计划类型,不会处理站内的数据。但是表里存在会增大主键区间。导致我们每个批次出来的数据比较稀疏。


(2)业务背景 由于百度量小,字节则最近进行了升级,历史物料不多,快手之前完全处于停投。所以去除出腾讯渠道,计划需要处理的数据量比较少18万(182934)。但是腾讯侧一直没有进行升级,而且量大,所以需要处理的单元比较多130万左右(1309692 )。


于是我们为了避免每个批次查出来要处理数据比较少,导致空跑,调大了步长。


再次执行任务


果然有效,计划时间段任务计,cpu虽然上去了,但是任务5分钟就执行完了。


执行执行单元时间段更新的时候,时间缩短到十几分钟,但是cpu却是这样的,顶着100%cpu跑任务。



道路且长,那我们怎么解决这个cpu问题呢,请看下一阶段。


(三阶段)减少临时对象大小和无效日志,避免多次ygc


这个cpu确实令人悲伤。当时我们


第一想法是,为了尽快满足产品需求,先用我们的组件事件总线进行负载(底层是用的mq)到多台机器。这样不但解决了cpu利用率高的问题,还能解决任务执行时间长的问题。这个想法确实能解决问题,但是还是耗用机器资源。


第二想法是,由于时间段在表里是个json存储,在执行查询的时候不好进行条件查询。于是想着单独在建一张表,拉平时间段,在进行查询的时候直接查新建的表,不再查询存储json时间段的表。但是这张表相当于异构了数据源,不但要新建表还要考虑这张表的维护。


于是我们继续分析cpu高用在哪里,理论上这个定时任务是IO型任务,cpu利用率应该比较低。在执行任务的时候,我们仔细观察了机器的监控,发现在执行单元时段更新任务时,机器每分钟不断地进行多次ygc。之前刚和组内同学分享过gc相关知识。这里说一下,虽然我们的机器用的是G1垃圾回收器,没有进行full gc,但是G1在ygc的时候会比jdk1.8默认的垃圾回收器要更耗资源,因为G1还要mixgc兼顾回收老年代的垃圾。G1用于响应优先,默认的垃圾回收器吞吐量优先。这样的批量任务其实更适合用默认垃圾回收器。


不断进行ygc肯定是因为我们在执行任务的时候产生大量的临时对象导致的。


这里我们采取了两条有效措施:


(1)去掉无效日志 由于调试时加了大量日志,java进行序列化的时候会产生比原来的对象占用更多内存的临时变量。于是我们去掉了所有的无效日志。


(2)减少临时对象占用的内存 代码对象的个数肯定不能减少,于是我们我们减少对象的的大小。之前是我们用的proxy工程现成接口,把表里的每个字段都查出来了,但是表里那么多字段,实际我们每张表也就用2-3个字段。于是我们为这个定时任务写了专用的查询接口,每个接口只查我们需要的字段。


结果果然有效,单元时间段更新任务从原来的顶着100%cpu跑了十几分钟,瞬间降到了cpu不到60%,五分钟执行完成。ycg次数也有明显的下降。


刷数任务: 这两个措施到底多有效呢,说另一个栗子也与这个需求相关。在没有减少临时变量大小(把单元表和单元扩展表中的所有字段都查出来)把单元表的启停状态和单元扩展表的审核状态刷到dsp_show_status时,涉及1400百万数据,刷了两个小时也没刷完,最后怕影响物料传输工程查询数据库给停了。之后减少临时变量后,九分钟就刷完了。


经过上述的优化看似皆大欢喜,但还存在很大的问题。给大家看一个监控图。



看完这个监控图,我们慌了,计划和单元更新时间段任务每半个小时运行一次,都给数据库带来了200万qpm的增长,这无疑给我们的数据库带来了巨大隐患。


此时总结下来存在两个问题有待解决。


(1)怎么减少与数据库的交互次数 ,消除给数据库带来的安全隐患。


(2)怎么降低任务的执行的时间, 五分钟的子状态更新延迟是不可以接受的。对广告主来说更是严重的bug。


这两个问题让我们觉得这个任务还有很大的优化空间,于是我们继续分析优化。下一阶段的措施很好的解决了这两个问题。


(四阶段)基于游标查询数据源,基于数据库分片批量更新,降低数据库交互次数,避免空跑缩短任务运行时间。


对于上面的问题,我们分析这么大的调用量主要用在了哪里。


发现由于站内数据的存在和历史数据的删除以及dsp_show_status和其他表公用一个主键id生成序列,导致dsp_show_status表的MaxAutoPk到达90多亿。


也就是所及时我们步长达到2万,光查询数据调用次数就达到了45万次,在加上每次都有可能产生小于四次的更新操作。也就是一个定时任务都会产生高大100万的qpm,两个任务产生200万也就符合预期了。于是我们把步长调整为4万,qpm降到了130万左右,但还是很高。



于是我们继续分析,就单元时间段更新任务而言,其实我们需要查出来的数据也就是上面提到的腾讯的130万左右(1309692 )。但是我们查询了45万次且步长是2万。也就是说我们每次查出来的数据还是很稀疏且个数不确定,如果忙盲目的调大步长,很可能由于某个区间数据量特别多导致负载不均衡,还有可能rpc超时。


那怎么才能做到每次查出来数据个数就是我们的设置的步长呢,我们想到了mysql里面的游标查询。但是jed弹性数据库并不支持,于是我们就要手动实现游标的逻辑。此时我们考虑dsp_show_status是否有唯一主键能标识唯一记录。假如主键不唯一,就有可能出现漏查和重复查询的情况。幸运的是我们的jed数据库所有的表里都有唯一主键。于是我们手写了一个游标查询。


(1)游标查询


伪代码如下


//上层业务代码
Long maxId = null;
do {
showStatuses = showStatusConsumer.betweenListByParam(
startAutoPkL, endAutoPkL, maxId,每次批次要查出来的数据,
其他条件(type=2单元层级,不包含已过期的数据,腾讯渠道)
)

if (CollectionsJ.isEmpty(showStatuses)) {
//如果为空的,直接推出,代表已经查到最后了。
break;
}
//循环变量值叠加,查出来的数据最后一行的id,数据库进行了升序,也就是这批记录的最大id
maxId = showStatuses.get(showStatuses.size() - 1).getId();

//处理查出来的数据
processShowStatuses( showStatuses);

} while (CollectionsJ.isNotEmpty(showStatuses));


//下层sql

SELECT
id,cga_id,status_bitmap1,user_id
FROM dsp_show_status
<where>
id BETWEEN #{startAutoPk,jdbcType=BIGINT} AND #{endAutoPk,jdbcType=BIGINT}
//param.maxId 上一批次查出数据的最大maxId
<if test="param.maxId != null">
AND id >#{param.maxId,jdbcType=BIGINT}

<----!其他条件------>

order by id
<if test="param.batchSize != null">
//上层传过来的每个批次要查询的出来的数据量
limit #{param.batchSize}



这里可以思考一下基于游标的查询方式在什么场景下有效? 如果有效需要满足一下两个条件


1.jed表里有唯一键,且基于唯一键查询排序


2.区间满足查询条件的记录越稀疏越有效


这里要一定注意排序的顺序,是升序不是降序。如果你无意间按降序排序,那么每次查询的都是最后的满足条件的batch大小的数据。


(2)深度分页引起慢sql


此时组内同学提出了一个疑问,深度分页引起慢sql问题。这里解释一下到底会不会产生慢sql。


当进行分页的时候一般sql会这样写


select *
from dsp_show_status
where 其他查询条件
limit 50000000 , 10;

当limit 的初始位置非常靠后时,即使压中查询条件里的二级索引,也需从二级索引得到的主键索引去加载所有的磁盘记录,然后扫描50000000行记录取50000000到-50000010条返回,这里涉及到记录的扫描,和多次磁盘到内存的IO,所以比较耗时。


但是我们的sql


select *
from dsp_show_status
where 其他查询条件
and id >maxId
oder by id
limit 100

当maxId非常大时,比如50000000 时,mysql压中查询条件的里的二级索引,得到主键索引。然后MySQL会直接过滤掉 id<50000000 的主键id,然后从主键50000000开始查询数据库得到满足条件的100条记录。所以他会非常快,并不是产生慢sql。实际sql执行只需要37毫秒。



(3) 按数据库分片进行批量更新


但是又遇到了另一个数据库长事务问题,由于使用了基于游标的方式,查出来的数据都是需要进行计算的数据,且任务运行时间缩短到到30秒。那在进行数据更新时,每次批量更新都比之前(不使用游标的方式)更新的数据量要多,且并发度高。其次由于批量更新的时候更新多个单元id,这些id不一定属于某一个user_id,所以在执行更新的时候没有带分片键,此时数据库jed网关又出现了问题。


当时业务日志的报错的信息是这样的,出现了执行时间超过了30秒的sql,被kill掉:


{"error":true,"exception":{"@type":"org.springframework.jdbc.UncategorizedSQLException","cause":{"@type":"com.mysql.cj.jdbc.exceptions.MySQLQueryInterruptedException","errorCode":1317,"localizedMessage":"transaction rolled back to reverse changes of partial DML execution: target: dsp_ads.c4-c8.primary: vttablet: (errno 2013) due to context deadline exceeded, elapsed time: 30.000434219s, killing query ID 3511786 (CallerID: )","message":"transaction rolled back to reverse changes of partial DML execution: target: dsp_ads.c4-c8.primary: vttablet: (errno 2013) due to context deadline exceeded, elapsed time: 30.000434219s, killing query ID 3511786 (CallerID: )","sQLState":"70100","stackTrace":[{"className":"com.mysql.cj.jdbc.exceptions.SQLError","fileName":"SQLError.java","lineNumber":126,"methodName":"createSQLException","nativeMethod":false},{"className":"com.mysql.cj.jdbc.exceptions.SQLError","fileName":"SQLError.java","lineNumber":97,"methodName":"createSQLException","nativeMethod":false},


数据库的监控也发现了异常,任务执行的时候出现了大量的MySQL rollbakc:



当时联系dba suport ,dba排查后告诉我们,我们的批量更新sql在数据库执行非常快,但是我们用了长事务超过30秒没有提交,所以被kill掉了。但是我们检查了我们的代码,发现并没有使用事务,且我们的事务是单库跨rpc事务,从发起事务到提交事务对于数据库来说执行时间非常快,并不会出现长事务。我们百思不得其解,经过思考我们觉得可能是jed网关出现了问题,jed网关的同学给的答复是。由于没有带分片键导致jed网关会把sql分发到64分片,如果某个分片上没有符合条件的记录,就会产生间隙锁,其他sql更新的时候一直锁更待从而导致事务一直没有提交出现长事务。


对于网关同学给我们的答复,我们仍然持有怀疑态度。本来我们想改下数据库的隔离级别验证一下这个回复,但是jed并不支持数据库隔离级别的更改。


但是无论如何我们知道了是因为我们批量更新时不带分片键导致的,但是如果按userId进行更新,将会导致原来只需要一次进行更新,现在需要多次更新。于是我们想到循环64分片数据库进行批量更新。但是jed并不支持执行sql时指定分片, 于是我们给他们提了需求。


后来我们想到了折中的方式,我们按数据库分片对要执行的单元id进行分组,保证每个分组对应的单元id落到数据库的一个分片上,并且执行更新的时候加上userId集合。这个方案要求jed网关在执行带有多个分片键sql时能进行路由。这边jed的同事验证了一下是可以的。



于是我们在进行更新的时候对这些ids按数据库分片进行了分组。


伪代码如下:


//按数据库分片进行分组
adgroups.stream().collect(Collectors.groupingBy(Adgroup::shardKey));
// 按计算每个userId对象的数据库分片,BinaryHashUtil是jed网关的jar包
public String shardKey() {
try {
return BinaryHashUtil.getShardByVindex(ShardEnum.SIXTY_FOUR_SHARDS, this.userId);
} catch (SQLException ex) {

throw new ApplicationException(ex);
}
}

在上述的刷数任务中能够执行那么快,并且更新数据没有报错,一方面也得益于这个按数据库分片进行分组更新数据


(4)优化效果


经过基于游标查询的方式进行任务优化,就单元时间段更新时。从原来的五分钟,瞬间降为30秒完成。cpu不到65% 。由于计划记录更稀疏,所以更快。



对数据库的查询更新操作,也从原来的也从原来的200万qpm降为2万多(早上高峰的时候),低峰的时候甚至不到两万。当我们把batchSize设置为100时,通过计算单元的130多万/100 +计划的18万/100=1.4万次qpm 也是符合预期的。


查询db监控:



更新db的监控,也符合预期



虽然引入基于游标的方式进行查询非常有效,把原来的200万qpm数据库交互降到了2万,把任务运行时间从5分钟降到了30秒。但是仔细分析你还会发现,还存在如下问题。


1、单台机器cpu高, 仍然在60%,对于健康的程序来说,这个数值仍然不被接受。


2、查询和更新数据量严重不符, 每次定时任务更新只更新了上万行记录,但是我们却查出来了上百万(130万)行记录进行子状态,这无疑还在浪费CPU和磁盘IO资源。


监控如下


每次查询出来的记录数:



每次需要更新的记录数:



经过上面的不断优化,我们更加相信,资源不能被浪费,作为程序员应该追求极致。于是我们还继续优化。解决上面两个问题


(五阶段)异构要更新状态的数据源,降低数据库交互次数,降低查询出来的数据量,降低机器cpu利用率。


为了减少无效数据查询和计算,我们还是决定冗余数据,但是不是像前面提到的新建一张表,而是在dsp_show_status 表里冗余一个nextTime字段,来存储这个物料下一次需要被定时任务拉起更改状态的时间戳,(也就是物料在投放时间段子状态和不在投放时间段子状态转变的时间戳),举个栗子,广告主设置某个单元早上8点开始投放,晚上8点结束投放,其他时间不投放。那早8点的时候,这个单元就会被我们的定时任务扫描到,然后计算更新这个单元从不投放变为投放,同时计算比较投放时间段,下一个状态变更的时间段,经过计算得知,广告主在晚上8点需要状态变更,也就是从投放变为不投放,那nextTime字段就落晚上8点的时间戳。这个字段的维护逻辑分为两部分,一部分是广告主主动更改了时间段需要更新计算这个nextTime,另一部分是定时任务拉起这个物料更改完子状态后,再次计算下一次需要被拉起的nextTime。


这样我们定时任务在查询数据源的时候只需新增一个查询条件(因为是存的是时间戳,所以需要卡个范围)就可以查出我们需要真正要更新的数据了。


当维护投放时间段这个异构数据,就要考虑异构数据和源数据的一致性问题。假如某次定时任务执行失败了,就会导致nextTime 和投放时间段数据不一致,此时我们的解决办法时,关闭基于nextTime的优化查询,进行上一阶段(第四阶段)基于游标的全量更新。


sql查询增加条件:
next_time_change between ADDTIME(#{param.nextTimeChange}, '-2:0:0')
and ADDTIME(#{param.nextTimeChange}, '0:30:0')

优化之后我们每次查询出来的记录从130万降到了1万左右。


11点的时候计划和单元总共查出来6000个,监控如下:



11点的时候计划和单元总共更新5000个,由于查询数据源的时候卡了时间戳范围,所以符合预期,查出来的个数基本就是要更新的记录。监控如下:



查询次数也从原来的1万次降到了200次。监控如下:



机器的监控如下cpu只用了28%,且只ygc了1次,任务执行时间30秒内完成。



这个增加next_time 这个字段进行查询的思路,和之前做监控审核中的创意定时任务类似。创意表20亿行数据,怎么从20亿行记录表里实时找出哪些创意正在审核中。当时的想法也是维护一个异构的redis数据源,送审的时候把数据写入redis,审核消息过来后再移除。但是当我们分析数据源的时候,幸运的发现审核中的创意在20亿数据中只占几万,大部分创意都是在审核通过和审核驳回,之前大家都了解到建立索引要考虑索引的区分度,但是在这种数据分布严重不均匀的场景,我们建立yn_status联合索引,在取数据源的时候,直接压数据库索引取出数据,sql执行的非常快,20毫秒左右就能执行完成,避免走了很多弯路。


你以为优化结束了? 不,合格的程序员怎么允许系统中存在cpu不稳定的场景存在,即使只增加28%


(六阶段)负载均衡,消除所有风险,让系统程序稳定运行。


消除单台机器cpu不稳定的最有效办法就是,把大任务拆分为小任务,然后分发到不同的机器上进行执行。我们的定时任务本来就是按批次进行查询计算的,所以本身就是小任务。剩下的就是分发任务,很多人想到的就是利用mq的负载进行分发,但是mq不可控,不可控制失败重试时间。如果一个小任务失败了,下次什么时候被拉起重试就不得而知了,或许半个小时以后?这里用到了我们非常牛逼的一个组件,可重试总线进行负载,支持自定义重试频率,支持自动识别无效重试,防止重试叠加。


负载后的机器cpu是这样的



优化效果数据汇总:


这里列一下任务从写出来到被优化后的数据对比。


优化前,cpu增加80%,任务运行半个小时,查询数据库次数百万次,查询出来130万行记录。


优化后,cpu增加1%,任务30秒以内,查询数据库200次,查询出来1万行记录。


写到最后:


通过本次优化让我收获许多,最大的收获是让我深刻明白了,对于编码人员,要时刻考虑资源的消耗。举个不太恰当的栗子,假如每个人在工程里都顺手打印一行无效日志,随着时间的积累整个工程都会到处打印在无效日志。毫不夸张的讲,或许只是因为你多打印了一行log.info日志,在请求量猛增达到一定程度时都会导致机器和应用的不良连锁反应。建议大家在开发的时候在关键点加上关键日志,并且合理利用Debugger,结合ducc进行动态日志调整排查问题。


作者:京东零售广告研发 董舒展
来源:juejin.cn/post/7339742783236702271
收起阅读 »

MyBatis-Plus 效能提升秘籍:掌握这些注解,事半功倍!

MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。一、@Tablename注解这个注解用于指定实体类对应的数据库表名。...
继续阅读 »

MyBatis-Plus是一个功能强大的MyBatis扩展插件,它提供了许多便捷的注解,让我们在开发过程中能够更加高效地完成数据库操作,本文将带你一一了解这些注解,并通过实例来展示它们的魅力。

一、@Tablename注解

这个注解用于指定实体类对应的数据库表名。如果你的表名和实体类名不一致,就需要用到它:

@TableName("user_info")
public class UserInfo {
// 类的属性和方法
}

在上述代码中,即使实体类名为UserInfo,但通过@TableName注解,我们知道它对应数据库中的"user_info"表。

二、@Tableld注解

每个数据库表都有主键,@TableId注解用于标识实体类中的主键属性。通常与@TableName配合使用,确保主键映射正确。

AUTO(0),
NONE(1),
INPUT(2),
ASSIGN_ID(3),
ASSIGN_UUID(4),
/** @deprecated */
@Deprecated
ID_WORKER(3),
/** @deprecated */
@Deprecated
ID_WORKER_STR(3),
/** @deprecated */
@Deprecated
UUID(4);

Description

  • INPUT 如果开发者没有手动赋值,则数据库通过自增的方式给主键赋值,如果开发者手动赋值,则存入该值。

  • AUTO 默认就是数据库自增,开发者无需赋值。

  • ASSIGN_ID MP 自动赋值,雪花算法。

  • ASSIGN_UUID 主键的数据类型必须是 String,自动生成 UUID 进行赋值。

// 自己赋值
//@TableId(type = IdType.INPUT)
// 默认使用的雪花算法,长度比较长,所以使用Long类型,不用自己赋值
@TableId
private Long id;

测试

@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("天明");
student.setAge(18);
mapper.insert(student);
}

Description

雪花算法

雪花算法是由Twitter公布的分布式主键生成算法,它能够保证不同表的主键的不重复性,以及相同表的主键的有序性。

核心思想:

  • 长度共64bit(一个long型)。

  • 首先是一个符号位,1bit标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0。

  • 41bit时间截(毫秒级),存储的是时间截的差值(当前时间截 - 开始时间截),结果约等于69.73年。

  • 10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID,可以部署在1024个节点)。

  • 12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID)。

Description

优点: 整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞,并且效率较高。

三、@TableField注解

当你的实体类属性名与数据库字段名不一致时,@TableField注解可以帮助你建立二者之间的映射关系。

  • 映射非主键字段,value 映射字段名;

  • exist 表示是否为数据库字段 false,如果实体类中的成员变量在数据库中没有对应的字段,则可以使用 exist,VO、DTO;

  • select 表示是否查询该字段;

  • fill 表示是否自动填充,将对象存入数据库的时候,由 MyBatis Plus 自动给某些字段赋值,create_time、update_time。

Description

自动填充

1)给表添加 create_time、update_time 字段。

Description

2)实体类中添加成员变量。

package com.md.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;

@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;

// 当该字段名称与数据库名字不一致
@TableField(value = "name")
private String name;

// 不查询该字段
@TableField(select = false)
private Integer age;

// 当数据库中没有该字段,就忽略
@TableField(exist = false)
private String gender;

// 第一次添加填充
@TableField(fill = FieldFill.INSERT)
private Date createTime;

// 第一次添加的时候填充,但之后每次更新也会进行填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

}

3)创建自动填充处理器。

注意:不要忘记添加 @Component 注解。

package com.md.handler;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
* @author md
* @Desc 对实体类中使用的自动填充注解进行编写
* @date 2020/10/26 17:29
*/
// 加入注解才能生效
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

@Override
public void insertFill(MetaObject metaObject) {
this.setFieldValByName("createTime", new Date(), metaObject);
this.setFieldValByName("updateTime", new Date(), metaObject);
}

@Override
public void updateFill(MetaObject metaObject) {
this.setFieldValByName("updateTime", new Date(), metaObject);
}
}

4)测试

@Test
void save(){
// 由于id加的有注解,这里就不用赋值了
Student student = new Student();
student.setName("韩立");
student.setAge(11);
// 时间自动填充
mapper.insert(student);
}

Description

5)更新

当该字段发生变化的时候时间会自动更新。

@Test
void update(){
Student student = mapper.selectById(1001);
student.setName("韩信");
mapper.updateById(student);
}

Description

四、@TableLogic注解

在很多应用中,数据并不是真的被删除,而是标记为已删除状态。@TableLogic注解用于标识逻辑删除字段,通常配合逻辑删除功能使用。

1、逻辑删除

物理删除: 真实删除,将对应数据从数据库中删除,之后查询不到此条被删除的数据。

逻辑删除: 假删除,将对应数据中代表是否被删除字段的状态修改为“被删除状态”,之后在数据库中仍旧能看到此条数据记录。

使用场景: 可以进行数据恢复。

2、实现逻辑删除

step1: 数据库中创建逻辑删除状态列。
Description

step2: 实体类中添加逻辑删除属性。

@TableLogic
@TableField(value = "is_deleted")
private Integer deleted;

3、测试

测试删除: 删除功能被转变为更新功能。

-- 实际执行的SQL
update user set is_deleted=1 where id = 1 and is_deleted=0

测试查询: 被逻辑删除的数据默认不会被查询。

-- 实际执行的SQL
select id,name,is_deleted from user where is_deleted=0

你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!点这里即可查看!

五、@Version注解

乐观锁是一种并发控制策略,@Version注解用于标识版本号字段,确保数据的一致性。

乐观锁

Description
标记乐观锁,通过 version 字段来保证数据的安全性,当修改数据的时候,会以 version 作为条件,当条件成立的时候才会修改成功。

version = 2

  • 线程1:update … set version = 2 where version = 1
  • 线程2:update … set version = 2 where version = 1

1.数据库表添加 version 字段,默认值为 1。

2.实体类添加 version 成员变量,并且添加 @Version。

package com.md.entity;

import com.baomidou.mybatisplus.annotation.*;
import com.md.enums.StatusEnum;
import lombok.Data;
import java.util.Date;

@Data
@TableName(value = "student")
public class Student {
@TableId
private Long id;
@TableField(value = "name")
private String name;
@TableField(select = false)
private Integer age;
@TableField(exist = false)
private String gender;
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

@Version
private Integer version; //版本号

}

3.注册配置类

在 MybatisPlusConfig 中注册 Bean。

package com.md.config;

import com.baomidou.mybatisplus.extension.plugins.OptimisticLockerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* @author md
* @Desc
* @date 2020/10/26 20:42
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 乐观锁
*/
@Bean
public OptimisticLockerInterceptor optimisticLockerInterceptor(){
return new OptimisticLockerInterceptor();
}
}

六、@EnumValue注解

mp框架对枚举进行处理的一个注解。

使用场景: 创建枚举类,在需要存储数据库的属性上添加@EnumValue注解。

public enum SexEnum {

MAN(1, "男"),
WOMAN(2, "女");

@EnumValue
private Integer key;
}

MyBatis-Plus的注解是开发者的好帮手,它们简化了映射配置,提高了开发效率。希望以上的介绍能帮助新手朋友们快速理解和运用这些常用注解,让你们在MyBatis-Plus的世界里游刃有余!记得实践是最好的学习方式,快去动手试试吧!

收起阅读 »

技术人的绩效评审发年终奖那些事儿

前言 这几天陆续开工了,收益不好的公司,没有年会,没有年终奖,没有开工红包,没有团建,也没有聚餐,唯一有的可能是降薪裁员... 收益好的公司开了年会,年终奖加倍... 接下来就来聊聊关于技术人的绩效评审以及年终奖那些事儿 以下基于个人经历展开讨论和思考,如果...
继续阅读 »


前言


这几天陆续开工了,收益不好的公司,没有年会,没有年终奖,没有开工红包,没有团建,也没有聚餐,唯一有的可能是降薪裁员...


收益好的公司开了年会,年终奖加倍...


接下来就来聊聊关于技术人的绩效评审以及年终奖那些事儿



以下基于个人经历展开讨论和思考,如果有不同的观点,欢迎交流探讨



关于年终奖


一般来讲,只要公司收益好,一般是多少都会发点年终奖的,例如13、4、5、6,7薪,过节费,项目奖,年终奖,xx绩效奖,部门xx奖等等



公司要是效益不好的话,可能就是这些各种发钱的项目就没有了,可能会有一点过节费,如果效益差到工资社保都拖欠的话,可能就是考虑能不能年底也发点工资的情况了


总的来说年终奖主要和公司收益挂钩,公司收益好,多少有点,收益不好,无


如果发年终奖的话,在相关制度下总量可能就那么多,发到个人这边一般就是和绩效挂钩了,绩效评级高,同类型岗位情况下发的多点,绩效低,发的少,那么公司是怎么对技术人进行绩效评审呢



微小型公司可能不需要绩效,发钱基本老板一个人就决定了


特殊情况的公司或者部门各种特殊的情况也有



这里分享一下我司今年出的年终奖方案



以下半年部门净收入目标完成率作为基础指标,实现目标销售净额的,按3%计提奖金池,未完成目标的,按3%×目标完成率计提奖金池,超过目标的,超过部分按10%增加奖金池。由分管领导与部门沟通后参照员工年终评估结果出具分配方案(年终评估为D或E员工无奖金)



负责人,其他前台部门等年终奖的计算方式是另外的方式,有兴趣的可以留言讨论,这里先不展开了


员工评估 D, E 是有硬指标的,具体占比百分之几咱也不知道,每年的评审结果都是只有领导知道


由于公司业务效益不行,净收入为负,亏损状态,所以我们这个部门的员工年终奖——无



关于绩效评审


先来看看我司对技术人员工的评估方式


第一步:直系领导直接打分,提交对应的表到人力部门


第二步:人力部门根据任务系统中的个人任务情况,以及考勤情况打分


第三步:人力总监最终决定给xxx员工涨薪,发奖金



日常任务由直系领导安排发放,包括任务工时评估,注意了,这里任务工时不是开发者评估的,大部分都是负责人直接评好写到周任务表上去的,有的任务用时会和开发者咨询协商


人力部门看的那个任务系统中的任务,一般是业务线负责人不忙的时候根据腾讯文档周任务表中的任务后期补上去的


日常的很多临时工作在周任务表上体现不出来的,然后很多周任务表中的任务没有写到任务系统中,任务系统中的任务只是创建,开始,完成状态,没有工时的体现


由于任务系统是人力部门单独推动的,最终的情况就是实际工作内容和任务系统记录其实是脱节状态,为了建任务而建任务


不同业务线的直系领导角色也不一样,有的是纯管理,有的是半后端开发半管理,有的是产品



总结一下就是直系领导决定主要的打分情况,人力部门根据基于一线负责人的打分结合考勤和任务情况,决定要不要涨薪,发多少奖金


以上就是我司绩效评审的一些情况,这也是相当一部分比例公司(部门/团队)的常规操作


从客观数据角度来看,基本没有体现岗位产出方面客观可量化的指标,唯一能量化的指标就是一个考勤了,两三个人的主观评价就决定了一个技术人的升职、加薪、辞退、奖金发放的问题


万物都有存在的道理,毕竟,大部分公司都是草台班子,甚至更水


从客观数据角度思考对于技术人的工作评审


先来看看技术人的实际工作都有哪些?


我们拿技术人中的开发人员来举例,从一个任务安排到工作完成提交都有哪些常见步骤:


参加需求评审会,了解需求,设计实现方案,代码编写,提交代码,线上测试,修复bug,输出文档,技术分享等等


开发人员工作产出一般通俗点讲就是开发了多少需求,功能,解决了多少bug等等


在技术圈内一般讨论一个开发人员是否大佬,是否能力出众的标准一般是:



  1. 解决问题的快慢程度(不局限于技术问题)

  2. 掌握技术栈的深度和广度

  3. 是否能和其他不同的工种、部门良好协作

  4. 技术方案执行落地能够良好取舍

  5. 定期更新技术栈,使用相对合适的技术解决业务问题

  6. 提交功能的bug数量

  7. 代码可读性是否良好

  8. 代码是否足够简洁优雅

  9. 开发的功能是否健壮

  10. 其他人接手可维护性是否容易

  11. 输出的文档是否专业,格式简洁明了

  12. ...


相对来讲,开发人员工作产出基本都是可以量化的,关于工作量化的问题,鲁迅说: 任何岗位的工作都是可以量化的


为了评审量化产出,也不能为了量化而量化,那样没任何意义了,浪费团队大把时间扯皮,还影响工作


关于量化产出激励团队方面,我个人比较喜欢敏捷开发那种模式,相对公正,公开


例如:在敏捷开发里面是日常工作是按照评估的人天,人时等方式


任务评估为了保证相对公平性是有这么一个前提的,团队岗位不是一个人评估


例如前端开发岗位,至少俩个人,一个人评估1人天,一个人评估10人天,这显然是有明显差距的,这种情况下一般 master 角色和团队其他成员参与讨论决定,master 也就是项目经理或者项目负责人的角色进行一定的把控


任务是个人根据需求池中的任务自己拉到个人任务表里,而不是单纯的分配,这种机制有个很不错的方式,就是优秀的人在一起会激发更强大的创造力



这里推荐看下美剧 《硅谷》 中 Dinesh 和 Gilfoyle 敏捷开发时的竞争桥段



还有就是不同的开发任务难易程度不一样,举个例子,有两个小任务,同样都是2人天,第一个任务需要大量的尝试新方案,进行相应的测试,并编写一定数据量的代码;第二个任务是使用现有方案,直接写一定量的代码,如常见的业务需求


这种情况的任务如果在项目工期相对紧张的情况,任务是自我选择的话绝大多数都会选择第二个任务,虽然量大,没有风险,不会出现研究过程某个地方卡壳导致任务延期(延期也有对应的处理机制,这里先不讨论)


但是这样很可能会出现难度高的任务会放到最后,没人做了,所以要有人为把控优先级,并且进行合理安排进行一定的调整


同样都是2天的任务,接受度是不一样的,这种任务一般会增加一个难度系数,也有的是进行难度分类,例如:难,一般,容易


最终任务产出统计时,工时还需要乘以难度系数


理想情况下以上的操作基本能实现大部分开发工作产出的量化,对于年终的工作评审结果相对公平客观



注意!!!


这里有个点,敏捷的机制是激励那些那些高效率高产出的人,是一种公开透明的激励机制


良好落地敏捷也是需要一定条件的,如团队需要有一定规模,并且要进行培训和认同这种机制,而且岗位对应人员至少是一个人,级别不能相差太大等等



每一种机制都是基于特定人群设计的,如果用错了人群可能机制流程就变成了纯形式化了


基于客观数据的评审方式


简单分析了一下后,基于客观数据相对公正的评审方式可看如下例子


日常任务得分 * 权重系数 + 直系领导打分 * 权重系数 + 部门领导/CXO 打分 * 权重系统 + 人事考勤得分 * 权重系数 + 其他得分


日常任务得分权重 > 领导打分权重 > ...


单纯衡量技术人的工作的话,应该主要以产出为主,其他环节占比相对小一点


相对来讲这种方式各方都有参与环节,基于数据数据说话是比较客观公正的


以上都是理想状态,一个公司的做事风格和方式和创始人有着直接关系,最终落地后的是个什么东西还得看公司老板和高管是如何做的


写在最后


这个世界没有绝对的公平合理



欢迎大家讨论交流,如果喜欢本文章或感觉文章有用,动动你那发财的小手点赞、收藏、关注再走呗 ^_^ 


微信公众号:草帽Lufei




作者:草帽lufei
来源:juejin.cn/post/7337630368907198502
收起阅读 »

麻了,一个操作把MySQL主从复制整崩了

前言 最近公司某项目上反馈mysql主从复制失败,被运维部门记了一次大过,影响到了项目的验收推进,那么究竟是什么原因导致的呢?而主从复制的原理又是什么呢?本文就对排查分析的过程做一个记录。 主从复制原理 我们先来简单了解下MySQL主从复制的原理。 主库m...
继续阅读 »

前言


最近公司某项目上反馈mysql主从复制失败,被运维部门记了一次大过,影响到了项目的验收推进,那么究竟是什么原因导致的呢?而主从复制的原理又是什么呢?本文就对排查分析的过程做一个记录。


主从复制原理


我们先来简单了解下MySQL主从复制的原理。




  1. 主库master 服务器会将 SQL 记录通过 dump 线程写入到 二进制日志binary log 中;

  2. 从库slave 服务器开启一个 io thread 线程向服务器发送请求,向 主库master 请求 binary log。主库master 服务器在接收到请求之后,根据偏移量将新的 binary log 发送给 slave 服务器。

  3. 从库slave 服务器收到新的 binary log 之后,写入到自身的 relay log 中,这就是所谓的中继日志。

  4. 从库slave 服务器,单独开启一个 sql thread 读取 relay log 之后,写入到自身数据中,从而保证主从的数据一致。


以上是MySQL主从复制的简要原理,更多细节不展开讨论了,根据运维反馈,主从复制失败主要在IO线程获取二进制日志bin log超时,一看主数据库的binlog日志竟达到了4个G,正常情况下根据配置应该是不超过300M。



binlog写入机制


想要了解binlog为什么达到4个G,我们来看下binlog的写入机制。


binlog的写入时机也非常简单,事务执行过程中,先把日志写到 binlog cache ,事务提交的时候,再把binlog cache写到binlog文件中。因为一个事务的binlog不能被拆开,无论这个事务多大,也要确保一次性写入,所以系统会给每个线程分配一个块内存作为binlog cache




  1. 上图的write,是指把日志写入到文件系统的page cache,并没有把数据持久化到磁盘,所以速度比较快

  2. 上图的fsync,才是将数据持久化到磁盘的操作, 生成binlog日志中


生产上MySQL中binlog中的配置max_binlog_size为250M, 而max_binlog_size是用来控制单个二进制日志大小,当前日志文件大小超过此变量时,执行切换动作。,该设置并不能严格控制Binlog的大小,尤其是binlog比较靠近最大值而又遇到一个比较大事务时,为了保证事务的完整性,可能不做切换日志的动作,只能将该事务的所有$QL都记录进当前日志,直到事务结束。一般情况下可采取默认值。


所以说怀疑是不是遇到了大事务,因而我们需要看看binlog中的内容具体是哪个事务导致的。


查看binlog日志


我们可以使用mysqlbinlog这个工具来查看下binlog中的内容,具体用法参考官网:https://dev.mysql.com/doc/refman/8.0/en/mysqlbinlog.html



  1. 查看binlog日志


./mysqlbinlog --no-defaults --base64-output=decode-rows -vv /mysqldata/mysql/binlog/mysql-bin.004816|more


  1. 以事务为单位统计binlog日志文件中占用的字节大小


./mysqlbinlog --no-defaults --base64-output=decode-rows -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep GTID -B1|grep '^# at' | awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|sort -n -r|more


生产中某个事务竟然占用4个G。



  1. 通过start-positionstop-position统计这个事务各个SQL占用字节大小


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816 |grep '^# at'| awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|sort -n -r|more


发现最大的一个SQL竟然占用了32M的大小,那超过10M的大概有多少个呢?



  1. 通过超过10M大小的数量


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep '^# at' | awk '{print $3}' | awk 'NR==1 {tmp=$1} NR>1 {print ($1-tmp, tmp, $1); tmp=$1}'|awk '$1>10000000 {print $0}'|wc -l


统计结果显示竟然有200多个,毛估一下,也有近4个G了



  1. 根据pos, 我们看下究竟是什么SQL导致的


./mysqlbinlog --no-defaults --base64-output=decode-rows --start-position='xxxx' --stop-position='xxxxx' -vv /mysqldata/mysql/binlog/mysql-bin.004816|grep '^# atxxxx' -C5| grep -v '###' | more


根据sql,分析了下,这个表正好有个blob字段,统计了下blob字段总合大概有3个G大小,然后我们业务上有个导入操作,这是一个非常大的事务,会频繁更新这表中记录的更新时间,导致生成binlog非常大。


问题: 明明只是简单的修改更新时间的语句,压根没有动blob字段,为什么生产的binlog这么大?因为生产的binlog采用的是row模式。


binlog的模式


binlog日志记录存在3种模式,而生产使用的是row模式,它最大的特点,是很精确,你更新表中某行的任何一个字段,会记录下整行的内容,这也就是为什么blob字段都被记录到binlog中,导致binlog非常大。此外,binlog还有statementmixed两种模式。



  1. STATEMENT模式 ,基于SQL语句的复制



  • 优点: 不需要记录每一行数据的变化,减少binlog日志量,节约IO,提高性能。

  • 缺点: 由于只记录语句,所以,在statement level下 已经发现了有不少情况会造成MySQL的复制出现问题,主要是修改数据的时候使用了某些定的函数或者功能的时候会出现。



  1. ROW模式,基于行的复制


5.1.5版本的MySQL才开始支持,不记录每条sql语句的上下文信息,仅记录哪条数据被修改了,修改成什么样了。



  • 优点: binlog中可以不记录执行的sql语句的上下文相关的信息,仅仅只需要记录那一条被修改。所以rowlevel的日志内容会非常清楚的记录下每一行数据修改的细节。不会出现某些特定的情况下的存储过程或function,以及trigger的调用和触发无法被正确复制的问题

  • 缺点: 所有的执行的语句当记录到日志中的时候,都将以每行记录的修改来记录,会产生大量的日志内容。



  1. MIXED模式


从5.1.8版本开始,MySQL提供了Mixed格式,实际上就是StatementRow的结合。


Mixed模式下,一般的语句修改使用statment格式保存binlog。如一些函数,statement无法完成主从复制的操作,则采用row格式保存binlog


总结


最终分析下来,我们定位到原来是由于大事务+blob字段大致binlog非常大,最终我们采用了修改业务代码,将blob字段单独拆到一张表中解决。所以,在设计开发过程中,要尽量避免大事务,同时在数据库建模的时候特别考虑将blob字段独立成表。


作者:JAVA旭阳
来源:juejin.cn/post/7231473194339532861
收起阅读 »

使用java自己简单搭建内网穿透

思路 内网穿透是一种网络技术,适用于需要远程访问本地部署服务的场景,比如你在家里搭建了一个网站或者想远程访问家里的电脑。由于本地部署的设备使用私有IP地址,无法直接被外部访问,因此需要通过公网IP实现访问。通常可以通过购买云服务器获取一个公网IP来实现这一目的...
继续阅读 »

思路


内网穿透是一种网络技术,适用于需要远程访问本地部署服务的场景,比如你在家里搭建了一个网站或者想远程访问家里的电脑。由于本地部署的设备使用私有IP地址,无法直接被外部访问,因此需要通过公网IP实现访问。通常可以通过购买云服务器获取一个公网IP来实现这一目的。


实际上,内网穿透的原理是将位于公司或其他工作地点的私有IP数据发送到云服务器(公网IP),再从云服务器发送到家里的设备(私有IP)。从私有IP到公网IP的连接是相对简单的,但是从公网IP到私有IP就比较麻烦,因为公网IP无法直接找到私有IP。


为了解决这个问题,我们可以让私有IP主动连接公网IP。这样,一旦私有IP连接到了公网IP,公网IP就知道了私有IP的存在,它们之间建立了连接关系。当公网IP收到访问请求时,就会通知私有IP有访问请求,并要求私有IP连接到公网IP。这样一来,公网IP就建立了两个连接,一个是用于访问的连接,另一个是与私有IP之间的连接。最后,通过这两个连接之间的数据交换,实现了远程访问本地部署服务的目的。


代码操作


打开IDEA创建一个mave项目,删除掉src,创建两个模块clientservice,一个是在本地的运行,一个是在云服务器上运行的,这边socket(tcp)连接,我使用的是AIO,AIO的函数回调看起来好复杂。


先编写service服务端,创建两个ServerSocket服务,一个是监听16000的,用来外来连接的,另一是监听16088是用来client访问的,也就是给serviceclient之间交互用的。先讲一个extListener他是监听16000,当有外部请求来时,也就是在公司访问时,先判断registerChannel是不是有clientservice,没有就关闭连接。有的话就下发指令告诉client有访问了赶快给我连接,连接会存在channelQueue队列里,拿到连接后,两个连接交换数据就行。


private static final int extPort = 16000;
private static final int clintPort = 16088;


private static AsynchronousSocketChannel registerChannel;

static BlockingQueue<AsynchronousSocketChannel> channelQueue = new LinkedBlockingQueue<>();

public static void main(String[] args) throws IOException {

final AsynchronousServerSocketChannel listener =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress("192.168.1.10", clintPort));

listener.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
public void completed(AsynchronousSocketChannel ch, Void att) {

// 接受连接,准备接收下一个连接
listener.accept(null, this);

// 处理连接
clintHandle(ch);
}

public void failed(Throwable exc, Void att) {
exc.printStackTrace();
}
});


final AsynchronousServerSocketChannel extListener =
AsynchronousServerSocketChannel.open().bind(new InetSocketAddress("localhost", extPort));

extListener.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {

private Future<Integer> writeFuture;

public void completed(AsynchronousSocketChannel ch, Void att) {
// 接受连接,准备接收下一个连接
extListener.accept(null, this);

try {
//判断是否有注册连接
if(registerChannel==null || !registerChannel.isOpen()){
try {
ch.close();
} catch (IOException e) {
e.printStackTrace();
}
return;
}
//下发指令告诉需要连接
ByteBuffer bf = ByteBuffer.wrap(new byte[]{1});
if(writeFuture != null){
writeFuture.get();
}
writeFuture = registerChannel.write(bf);

AsynchronousSocketChannel take = channelQueue.take();

//clint连接失败的
if(take == null){
ch.close();
return;
}

//交换数据
exchangeDataHandle(ch,take);

} catch (Exception e) {
e.printStackTrace();
}

}

public void failed(Throwable exc, Void att) {
exc.printStackTrace();
}
});

Scanner in = new Scanner(System.in);
in.nextLine();


}

看看clintHandle方法是怎么存进channelQueue里的,很简单client发送0,就认为他是注册的连接,也就交互的连接直接覆盖registerChannel,发送1的话就是用来交换数据的,扔到channelQueue,发送2就异常的连接。


private static void clintHandle(AsynchronousSocketChannel ch) {

final ByteBuffer buffer = ByteBuffer.allocate(1);
ch.read(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {
buffer.flip();
byte b = buffer.get();
if (b == 0) {
registerChannel = ch;
} else if(b == 1){
channelQueue.offer(ch);
}else{
//clint连接不到
channelQueue.add(null);
}

}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
}

再编写client客户端,dstHostdstPort是用来连接service的ip和端口,看起来好长,实际上就是client连接service,第一个连接成功后向service发送了个0告诉他是注册的连接,用来交换数据。当这个连接收到service发送的1时,就会创建新的连接去连接service


private static final String dstHost = "192.168.1.10";
private static final int dstPort = 16088;

private static final String srcHost = "localhost";
private static final int srcPort = 3389;


public static void main(String[] args) throws IOException {

System.out.println("dst:"+dstHost+":"+dstPort);
System.out.println("src:"+srcHost+":"+srcPort);

//使用aio
final AsynchronousSocketChannel client = AsynchronousSocketChannel.open();

client.connect(new InetSocketAddress(dstHost, dstPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {
//连接成功
byte[] bt = new byte[]{0};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
client.write(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {

//读取数据
final ByteBuffer buffer = ByteBuffer.allocate(1);
client.read(buffer, null, new CompletionHandler<Integer, Void>() {
public void completed(Integer result, Void attachment) {
buffer.flip();

if (buffer.get() == 1) {
//发起新的连
try {
createNewClient();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
buffer.clear();
// 这里再次调用读取操作,实现循环读取
client.read(buffer, null, this);
}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
Scanner in = new Scanner(System.in);
in.nextLine();

}

createNewClient方法,尝试连接本地服务,如果失败就发送2,成功就发送1,这个会走 serviceclintHandle方法,成功的话就会让两个连接交换数据。


private static void createNewClient() throws IOException {

final AsynchronousSocketChannel dstClient = AsynchronousSocketChannel.open();
dstClient.connect(new InetSocketAddress(dstHost, dstPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {

//尝试连接本地服务
final AsynchronousSocketChannel srcClient;
try {
srcClient = AsynchronousSocketChannel.open();
srcClient.connect(new InetSocketAddress(srcHost, srcPort), null, new CompletionHandler<Void, Void>() {
public void completed(Void result, Void attachment) {

byte[] bt = new byte[]{1};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
Future<Integer> write = dstClient.write(buffer);
try {
write.get();
//交换数据
exchangeData(srcClient, dstClient);
exchangeData(dstClient, srcClient);
} catch (Exception e) {
closeChannels(srcClient, dstClient);
}


}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
//失败
byte[] bt = new byte[]{2};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
dstClient.write(buffer);
}
});

} catch (IOException e) {
e.printStackTrace();
//失败
byte[] bt = new byte[]{2};
final ByteBuffer buffer = ByteBuffer.wrap(bt);
dstClient.write(buffer);
}

}

public void failed(Throwable exc, Void attachment) {
exc.printStackTrace();
}
});
}

下面是exchangeData交换数据方法,看起好麻烦,效果就类似IOUtils.copy(InputStream,OutputStream),一个流写入另一个流。


private static void exchangeData(AsynchronousSocketChannel ch1, AsynchronousSocketChannel ch2) {
try {
final ByteBuffer buffer = ByteBuffer.allocate(1024);

ch1.read(buffer, null, new CompletionHandler<Integer, CompletableFuture<Integer>>() {

public void completed(Integer result, CompletableFuture<Integer> readAtt) {

CompletableFuture<Integer> future = new CompletableFuture<>();

if (result == -1 || buffer.position() == 0) {
// 处理连接关闭的情况或者没有数据可读的情况

try {
readAtt.get(3,TimeUnit.SECONDS);
} catch (Exception e) {
e.printStackTrace();
}

closeChannels(ch1, ch2);
return;
}

buffer.flip();

CompletionHandler readHandler = this;

ch2.write(buffer, future, new CompletionHandler<Integer, CompletableFuture<Integer>>() {
@Override
public void completed(Integer result, CompletableFuture<Integer> writeAtt) {

if (buffer.hasRemaining()) {
// 如果未完全写入,则继续写入
ch2.write(buffer, writeAtt, this);

} else {
writeAtt.complete(1);
// 清空buffer并继续读取
buffer.clear();
if(ch1.isOpen()){
ch1.read(buffer, writeAtt, readHandler);
}
}

}

@Override
public void failed(Throwable exc, CompletableFuture<Integer> attachment) {
if(!(exc instanceof AsynchronousCloseException)){
exc.printStackTrace();
}
closeChannels(ch1, ch2);
}
});

}

public void failed(Throwable exc, CompletableFuture<Integer> attachment) {
if(!(exc instanceof AsynchronousCloseException)){
exc.printStackTrace();
}
closeChannels(ch1, ch2);
}
});

} catch (Exception ex) {
ex.printStackTrace();
closeChannels(ch1, ch2);
}

}

private static void closeChannels(AsynchronousSocketChannel ch1, AsynchronousSocketChannel ch2) {
if (ch1 != null && ch1.isOpen()) {
try {
ch1.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (ch2 != null && ch2.isOpen()) {
try {
ch2.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}

测试


我这边就用虚拟机来测试,用云服务器就比较麻烦,得登录账号,增加开放端口规则,上传代码。我这边用Hyper-V快速创建了虚拟机,创建一个windows 10 MSIX系统,安装JDK8,下载地址:http://www.azul.com/downloads/?… 。怎样把本地编译好的class放到虚拟机呢,虚拟机是可以访问主机ip的,我们可以弄一个web的文件目录下载给虚拟机访问,人生苦短我用pyhton,下面python简单代码


if __name__ == '__main__':
# 定义服务器的端口
PORT = 8000

# 创建请求处理程序
Handler = http.server.SimpleHTTPRequestHandler

# 设置工作目录
os.chdir("C:\netTunnlDemo\client\target")

# 创建服务器
with socketserver.TCPServer(("", PORT), Handler) as httpd:
print(f"服务启动在端口 {PORT}")
httpd.serve_forever()

到class的目录下运行cmd,执行java -cp . org.example.Main,windows 默认远程端口3389。


最后效果


QQ截图20240225075018.png


总结


使用AIO导致代码长,逻辑并不复杂,完整代码,供个人学习:断续/netTunnlDemo (gitee.com)


作者:cloudy491
来源:juejin.cn/post/7338973258895802431
收起阅读 »

MyBatis实现多行合并(collection标签使用)

一、举个栗子 现有如下表结构,用户表、角色表、用户角色关联表。 一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景。 现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色。 从上面的表格我们可以看出,用...
继续阅读 »

一、举个栗子


现有如下表结构,用户表、角色表、用户角色关联表。
一个用户有多个角色,一个角色有可以给多个用户,也即常见的多对多场景
在这里插入图片描述


现有这样一个需求,分页查询用户数据,除了用户ID和用户名称字段,还要查出这个用户的所有角色
在这里插入图片描述
从上面的表格我们可以看出,用户有三个,但每个人的角色不止一个,而且有重复的角色,这里角色的数据从多行合并到了1行


二、难点分析


SQL存在的问题:



想使用SQL实现上面的效果不是不可以,但是很复杂且效率低下,尤其这个地方还需要分页,所以为了保证查询效率,我们需要把逻辑放到服务端来写;



服务端存在的问题:



服务端可以把需要的数据都查询出来,然后自己判断整合,首先十分复杂不说,而且这里有个问题:如果角色也是一个查询条件如何处理呢?



三、解决方案


核心方案就是使用Mybatis的collection标签自动实现多行合并。


下面是collection标签的一些介绍
在这里插入图片描述


常见写法


<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>

四、尝试一下


1. 准备材料


(1)数据库脚本


/*
Navicat Premium Data Transfer

Source Server : 本机
Source Server Type : MySQL
Source Server Version : 80021
Source Host : localhost:3306
Source Schema : mybatis-test

Target Server Type : MySQL
Target Server Version : 80021
File Encoding : 65001

Date: 23/06/2022 19:16:34
*/


SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for my_role
-- ----------------------------
DROP TABLE IF EXISTS `my_role`;
CREATE TABLE `my_role` (
`role_id` int NOT NULL COMMENT '角色主键',
`role_code` varchar(32) DEFAULT NULL COMMENT '角色code',
`role_name` varchar(32) DEFAULT NULL COMMENT '角色名称',
PRIMARY KEY (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_role
-- ----------------------------
BEGIN;
INSERT INTO `my_role` VALUES (1, 'admin', '超级管理员');
INSERT INTO `my_role` VALUES (2, 'visitor', '游客');
COMMIT;

-- ----------------------------
-- Table structure for my_user
-- ----------------------------
DROP TABLE IF EXISTS `my_user`;
CREATE TABLE `my_user` (
`user_id` int NOT NULL COMMENT '用户主键',
`user_name` varchar(32) DEFAULT NULL COMMENT '用户名称',
`user_gender` tinyint DEFAULT NULL COMMENT '用户性别,1:男/2:女/3:未知',
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user
-- ----------------------------
BEGIN;
INSERT INTO `my_user` VALUES (1, '用户1', 1);
COMMIT;

-- ----------------------------
-- Table structure for my_user_role_rel
-- ----------------------------
DROP TABLE IF EXISTS `my_user_role_rel`;
CREATE TABLE `my_user_role_rel` (
`rel_id` int NOT NULL COMMENT '角色主键',
`role_id` int DEFAULT NULL COMMENT '角色ID',
`user_id` int DEFAULT NULL COMMENT '用户ID',
PRIMARY KEY (`rel_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

-- ----------------------------
-- Records of my_user_role_rel
-- ----------------------------
BEGIN;
INSERT INTO `my_user_role_rel` VALUES (1, 1, 1);
INSERT INTO `my_user_role_rel` VALUES (2, 2, 1);
COMMIT;

SET FOREIGN_KEY_CHECKS = 1;


(2)pom.xml


<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">

<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>mybatis-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mybatis-test</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.16.22</version>
</dependency>
<!-- 我这里使用的是mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>javax.persistence-api</artifactId>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>


(3)application.properties


# 数据库配置
spring.datasource.type=com.zaxxer.hikari.HikariDataSource
spring.datasource.url=jdbc:mysql://localhost:3306/mybatis-test?characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
spring.datasource.username=root
spring.datasource.password=xxx
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# mybatis
mybatis.configuration.auto-mapping-behavior=full
mybatis.configuration.map-underscore-to-camel-case=true
mybatis-plus.mapper-locations=classpath*:/mybatis/mapper/*.xml

2. 项目代码


(1)目录结构


在这里插入图片描述


(2)各类代码


MybatisTestApplication.java


import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan({"com.example.mybatistest.mapper"})
public class MybatisTestApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisTestApplication.class, args);
}

}


QueryController.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/mybatis")
public class QueryController {

@Autowired
private UserRoleRelService userRoleRelService;

@GetMapping("/queryList")
public List<UserInfoDO> queryList() {
return userRoleRelService.queryList();
}
}

UserRoleRelService.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface UserRoleRelService {
List<UserInfoDO> queryList();
}

UserRoleRelServiceImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import com.example.mybatistest.service.UserRoleRelService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class UserRoleRelServiceImpl implements UserRoleRelService {

@Autowired
private MyUserRoleRelRepository myUserRoleRelRepository;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelRepository.queryList();
}
}

MyUserRoleRelRepository.java


import com.example.mybatistest.entity.UserInfoDO;

import java.util.List;

public interface MyUserRoleRelRepository {
List<UserInfoDO> queryList();
}

MyUserRoleRelRepositoryImpl.java


import com.example.mybatistest.entity.UserInfoDO;
import com.example.mybatistest.mapper.MyUserRoleRelMapper;
import com.example.mybatistest.repository.MyUserRoleRelRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public class MyUserRoleRelRepositoryImpl implements MyUserRoleRelRepository {
@Autowired
public MyUserRoleRelMapper myUserRoleRelMapper;

@Override
public List<UserInfoDO> queryList() {
return myUserRoleRelMapper.queryList();
}
}

MyUserRoleRelMapper.java


import com.example.mybatistest.entity.UserInfoDO;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface MyUserRoleRelMapper {
List<UserInfoDO> queryList();
}

MyRole.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;


@Builder
@Data
@TableName("my_role")
@NoArgsConstructor
@AllArgsConstructor
public class MyRole {

@Column(name = "role_id")
private Integer roleId;

@Column(name = "role_name")
private String roleName;
}

MyUserRoleRel.java


import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;

@Builder
@Data
@TableName("my_user_role_rel")
@NoArgsConstructor
@AllArgsConstructor
public class MyUserRoleRel {

@Column(name = "rel_id")
private Integer relId;

@Column(name = "user_id")
private Integer userId;

@Column(name = "role_id")
private Integer roleId;
}

UserInfoDO.java


import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoDO {
private Integer userId;

private String userName;

private List<MyRole> roleList;
}

MyUserRoleRelMapper.xml


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mybatistest.mapper.MyUserRoleRelMapper">
<resultMap id="BaseResultMap" type="com.example.mybatistest.entity.MyUserRoleRel">
<!--
WARNING - @mbg.generated
-->

<result column="rel_id" jdbcType="INTEGER" property="relId"/>
<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="user_id" jdbcType="INTEGER" property="userId"/>
</resultMap>
<resultMap id="ExtraBaseResultMap" type="com.example.mybatistest.entity.UserInfoDO">
<!--
WARNING - @mbg.generated
-->

<result column="user_id" jdbcType="INTEGER" property="userId"/>
<result column="user_name" jdbcType="INTEGER" property="userName"/>
<collection javaType="java.util.ArrayList" ofType="com.example.mybatistest.entity.MyRole"
property="roleList">

<result column="role_id" jdbcType="INTEGER" property="roleId"/>
<result column="role_name" jdbcType="VARCHAR" property="roleName"/>
</collection>
</resultMap>
<select id="queryList" resultMap="ExtraBaseResultMap">
SELECT
t3.user_id,
t3.user_name,
t2.role_id,
t2.role_name
FROM
my_user_role_rel t1
LEFT JOIN my_role t2 ON t1.role_id = t2.role_id
LEFT JOIN my_user t3 ON t1.user_id = t3.user_id
</select>
</mapper>

3. 实现效果


在这里插入图片描述


这里可以看到roleList里面有两条数据,说明mybatis已经自动聚合完成了。


4. 一些缺点


缺点1、查询条件不能支持很多


虽然Mybatis可以帮我们实现多行合并的功能,但并不是没有问题的。
当使用角色当做查询条件时,由于角色已经指定了,那么roleList里面必定只有这一个角色,不再会有聚合效果,也就看不到这个用户所有的角色了。
我只能说具体看产品要求吧,大部分时候上面那种问题产品都是可以接受的。


缺点2、不支持分页


这个缺点也看业务场景吧,产品可以接受就用,不能接受就别用,我这里只是介绍有这么一个办法。


作者:summo
来源:juejin.cn/post/7337849561479708735
收起阅读 »

微服务下,如何实现多设备同时登录或强制下线?

分享技术,用心生活 前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。 先来看一下简单的时序图,方便后续理解。 sequenceDi...
继续阅读 »

分享技术,用心生活





前言:你有没有遇到过这样的需求,产品要求实现同一个用户根据后台设置允许同时登录,或者不准同时登录时,需要强制踢下线前一个的场景。本文将带领大家实现一个简单的这种场景需求。





先来看一下简单的时序图,方便后续理解。


sequenceDiagram
用户->>过滤器: 请求登录
过滤器->>业务系统: 是否允许同时登录
业务系统-->>过滤器: 返回是/否
过滤器-->>用户: 登录成功(踢下线)

首先我们需要有一个后台设置开关来控制允不允许用户多设备同时登录的功能(没有也无妨,假定允许),其次在登录后,需要保存用户的userId-token的关系缓存。再回头看上面的时序图,是不是已经能理解实现的原理了。


如果你的架构是微服务,那么可以使用redis来存登录关系缓存,单体架构可以直接存session即可。本文是微服务架构,所以采用的是redis。


本文的前提都是基于同一个用户的情况下,下文不再赘述。


1 构造登录缓存关系


如果要实现同一用户多设备同时登录,那必然需要在session(微服务中可以用redis做session共享)中能找到用户的每一个登录状态,如果只是简单的缓存用户信息是实现不了的,登录时那就必须要有一个唯一值token,这样每次登录token不一样,但是指向的用户是同一个。


user


usertoken中维护的是前缀:用户id,这里不需要维护多个,因为用的reids的hash数据类型,多个登录时,添加新行即可;user部分,这里维护的是多个,即登录一次就有一条记录;因为根据业务需要,后续需要从缓存中获取用户其他信息。



  • 允许多设备同时登录:usertoken只有1条,user可能会有多条

  • 不允许多设备同时登录(有则强制下线):usertoken只有1条,user只有1条


    /**
* 登录成功后缓存用户信息
*
* @param req
* @return
*/

public void cacheUserInfo(CacheUserInfoReqDTO req) {
// 1、缓存用户信息
cacheUser(req);
cacheAuth(req.getUid(), req.getRoles(), req.getPermissions());

// 2、更新token与userId关系
String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + req.getUid());
redisAdapter.set(userTokenRelationKey, req.getToken(), RedisTtl.USER_LOGIN_SUCCESS);
}

2 过滤器配置


登录鉴权部分和用户登录状态上下文不在本文范围内,此处忽略


登录成功后,每一个请求到达过滤器时,通过请求header中的token来获取登录信息;因为我们存的缓存key前缀都包含userId,所以要想得到用户信息,需要使用到redis的scan命令来获取。(scan最好配置count来限制,保证性能


@Override
protected Mono<Void> innerFilter(ServerWebExchange exchange, WebFilterChain chain) {
String token = filterContext.getToken();
if (StringUtils.isBlank(token)) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_MISSING);
}

// scan获取user的key
String userKey = "";
Set<String> scan = redisAdapter.scan(GatewayRedisKeyPrefix.USER_KEY.getKey() + "*" + token);
if (scan.isEmpty()) {
throw new DataValidateException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}
userKey = scan.iterator().next();

MyUser myUser = (MyUser) redisAdapter.get(userKey);
if (myUser == null) {
throw new BusinessException(GatewayReturnCodes.TOKEN_EXPIRED_LOGIN_SUCCESS);
}

// 将用户信息塞入http header
// do something...
return chain.filter(exchange.mutate().request(newServerHttpRequest).build());
}

这样保证即使有多设备同时登录,也能获取到登录信息和上下文。


3 如何做强制下线呢?


其实也很简单,在登录前可以通过AOP方式做校验,如果已登录了,那么这里就清除session或用户缓存,再继续进行正常登录即可。再简单一点可以直接在登录service中添加校验


核心逻辑


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntList.get(0).getUserId());
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
if (StringUtils.isNotEmpty(redisToken) && !redisToken.equals(token)) {
throw new BusinessException(UserReturnCodes.MULTI_DEVICE_LOGIN);
}

这里用于判断是否已有登录,并返回给前端提示。用于前端其他业务处理
如果不需要给前端提示,不用返回前端,直接进行清除session或用户缓存逻辑。


 String userTokenRelationKey = RedisKeyHelper.getUserTokenRelationKey(req.getEntId() + SymbolConstant.COLON + userEntity.getId());
// 获取当前已用户的登录token
String redisToken = (String) redisAdapter.get(userTokenRelationKey);
// 踢下线之前全部登录
Response<Void> exitLoginResponse = gatewayRpc.allExit(ExitLoginReqDTO.builder().token(redisToken).userId(userEntity.getId()).build());

4 演示



  • 演示强制下线


这里我用用户id为4做演示


先正常第一次登录,提示成功,并且redis中有1条user记录
redis_succ
redis_succ


再次登录,我这里是返回给前端处理了,所以会有提示信息。


login


前端效果


message


最后,扩展一下,如果要实现登录后强制修改默认密码、登录时间段限制等场景,你会怎么实现呢?


作者:临时工
来源:juejin.cn/post/7258155447831920700
收起阅读 »

听我一句劝,业务代码中,别用多线程。

你好呀,我是歪歪。 前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。 虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。 我只是微微一笑,这不是很正常吗? 业务代码中一般也使不上多线...
继续阅读 »

你好呀,我是歪歪。


前几天我在网上冲浪,看到一个哥们在吐槽,说他工作三年多了,没使用过多线程。


虽然八股文背的滚瓜烂熟,但是没有在实际开发过程中写的都是业务代码,没有使用过线程池,心里还是慌得一比。


我只是微微一笑,这不是很正常吗?


业务代码中一般也使不上多线程,或者说,业务代码中不知不觉你以及在使用线程池了,你再 duang 的一下搞一个出来,反而容易出事。


所以提到线程池的时候,我个人的观点是必须把它吃得透透的,但是在业务代码中少用或者不用多线程。


关于这个观点,我给你盘一下。


Demo


首先我们还是花五分钟搭个 Demo 出来。


我手边刚好有一个之前搭的一个关于 Dubbo 的 Demo,消费者、生产者都有,我就直接拿来用了:



这个 Demo 我也是跟着网上的 quick start 搞的:



cn.dubbo.apache.org/zh-cn/overv…




可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。


我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:



在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。


而官方的示例中,是基于了 SpringBoot 的 CommandLineRunner 去发起调用:



只是发起调用的方式不一样而已,其他没啥大区别。


需要说明的是,我只是手边刚好有一个 Dubbo 的 Demo,随手就拿来用了,但是本文想要表达的观点,和你使不使用 Dubbo 作为 RPC 框架,没有什么关系,道理是通用的。


上面这个 Demo 启动起来之后,通过 Http 接口发起一次调用,看到控制台服务提供方和服务消费方都有对应的日志输出,准备工作就算是齐活儿了:



上菜


在上面的 Demo 中,这是消费者的代码:



这是提供者的代码:



整个调用链路非常的清晰:



来,请你告诉我这里面有线程池吗?


没有!


是的,在日常的开发中,我就是写个接口给别人调用嘛,在我的接口里面并没有线程池相关的代码,只有 CRUD 相关的业务代码。


同时,在日常的开发中,我也经常调用别人提供给我的接口,也是一把梭,撸到底,根本就不会用到线程池。


所以,站在我,一个开发人员的角度,这个里面没有线程池。


合理,非常合理。


但是,当我们换个角度,再看看,它也是可以有的。


比如这样:



反应过来没有?


我们发起一个 Http 调用,是由一个 web 容器来处理这个请求的,你甭管它是 Tomcat,还是 Jetty、Netty、Undertow 这些玩意,反正是个 web 容器在处理。


那你说,这个里面有线程池吗?


在方法入口处打个断点,这个 http-nio-8081-exec-1 不就是 Tomcat 容器线程池里面的一个线程吗:



通过 dump 堆栈信息,过滤关键字可以看到这样的线程,在服务启动起来,啥也没干的情况下,一共有 10 个:



朋友,这不就是线程池吗?


虽然不是你写的,但是你确实用了。


我写出来的这个 test 接口,就是会由 web 容器中的一个线程来进行调用。所以,站在 web 容器的角度,这里是有一个线程池的:



同理,在 RPC 框架中,不管是消费方,还是服务提供方,也都存在着线程池。


比如 Dubbo 的线程池,你可以看一下官方的文档:



cn.dubbo.apache.org/zh-cn/overv…




而对于大多数的框架来说,它绝不可能只有一个线程池,为了做资源隔离,它会启用好几个线程池,达到线程池隔离,互不干扰的效果。


比如参与 Dubbo 一次调用的其实不仅一个线程池,至少还有 IO 线程池和业务线程池,它们各司其职:



我们主要关注这个业务线程池。


反正站在 Dubbo 框架的角度,又可以补充一下这个图片了:



那么问题来了,在当前的这个情况下?


当有人反馈:哎呀,这个服务吞吐量怎么上不去啊?


你怎么办?


你会 duang 的一下在业务逻辑里面加一个线程池吗?



大哥,前面有个 web 容器的线程池,后面有个框架的线程池,两头不调整,你在中间加个线程池,加它有啥用啊?


web 容器,拿 Tomcat 来说,人家给你提供了线程池参数调整的相关配置,这么一大坨配置,你得用起来啊:



tomcat.apache.org/tomcat-9.0-…




再比如 Dubbo 框架,都给你明说了,这些参数属于性能调优的范畴,感觉不对劲了,你先动手调调啊:



你把这些参数调优弄好了,绝对比你直接怼个线程池在业务代码中,效果好的多。


甚至,你在业务代码中加入一个线程池之后,反而会被“反噬”。


比如,你 duang 的一下怼个线程池在这里,我们先只看 web 容器和业务代码对应的部分:



由于你的业务代码中有线程池的存在,所以当接受到一个 web 请求之后,立马就把请求转发到了业务线程池中,由线程池中的线程来处理本次请求,从而释放了 web 请求对应的线程,该线程又可以里面去处理其他请求。


这样来看,你的吞吐量确实上去了。


在前端来看,非常的 nice,请求立马得到了响应。


但是,你考虑过下游吗?


你的吞吐量上涨了,下游同一时间处理的请求就变多了。如果下游跟不上处理,顶不住了,直接就是崩给你看怎么办?



而且下游不只是你一个调用方,由于你调用的太猛,导致其他调用方的请求响应不过来,是会引起连锁反应的。


所以,这种场景下,为了异步怼个线程池放着,我觉得还不如用消息队列来实现异步化,顶天了也就是消息堆积嘛,总比服务崩了好,这样更加稳妥。


或者至少和下游勾兑一下,问问我们这边吞吐量上升,你们扛得住不。


有的小伙伴看到这里可能就会产生一个疑问了:歪师傅,你这个讲得怎么和我背的八股文不一样啊?


巧了,你背过的八股文我也背过,现在我们来温习一下我们背过的八股文。


什么时候使用线程池呢?


比如一个请求要经过若干个服务获取数据,且这些数据没有先后依赖,最终需要把这些数据组合起来,一并返回,这样经典的场景:



用户点商品详情,你要等半天才展示给用户,那用户肯定骂骂咧咧的久走了。


这个时候,八股文上是怎么说的:用线程池来把串行的动作改成并行。



这个场景也是增加了服务 A 的吞吐量,但是用线程池就是非常正确的,没有任何毛病。


但是你想想,我们最开始的这个案例,是这个场景吗?



我们最开始的案例是想要在业务逻辑中增加一个线程池,对着一个下游服务就是一顿猛攻,不是所谓的串行改并行,而是用更多的线程,带来更多的串行。


这已经不是一个概念了。


还有一种场景下,使用线程池也是合理的。


比如你有一个定时任务,要从数据库中捞出状态为初始化的数据,然后去调用另外一个服务的接口查询数据的最终状态。



如果你的业务代码是这样的:


//获取订单状态为初始化的数据(0:初始化 1:处理中 2:成功 3:失败)
//select * from order where order_status=0;
ArrayList initOrderInfoList = queryInitOrderInfoList();
//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //捕获异常以免一条数据错误导致循环结束
    try{
        //发起rpc调用
        String orderStatus = queryOrderStatus(orderInfo.getOrderId);
        //更新订单状态
        updateOrderInfo(orderInfo.getOrderId,orderStatus);  
    } catch (Exception e){
        //打印异常
    }
}

虽然你框架中使用了线程池,但是你就是在一个 for 循环中不停的去调用下游服务查询数据状态,是一条数据一条数据的进行处理,所以其实同一时间,只是使用了框架的线程池中的一个线程。


为了更加快速的处理完这批数据,这个时候,你就可以怼一个线程池放在 for 循环里面了:


//循环处理这批数据
for(OrderInfo orderInfo : initOrderInfoList){
    //使用线程池
    executor.execute(() -> {
        //捕获异常以免一条数据错误导致循环结束
        try {
            //发起rpc调用
            String orderStatus = queryOrderStatus(orderInfo.getOrderId);
            //更新订单状态
            updateOrderInfo(orderInfo.getOrderId, orderStatus);
        } catch (Exception e) {
            //打印异常
        }
    });
}


需要注意的是,这个线程池的参数怎么去合理的设置,是需要考虑的事情。


同时这个线程池的定位,就类似于 web 容器线程池的定位。


或者这样对比起来看更加清晰一点:



定时任务触发的时候,在发起远程接口调用之前,没有线程池,所以我们可以启用一个线程池来加快数据的处理。


而 Http 调用或者 RPC 调用,框架中本来就已经有一个线程池了,而且也给你提供了对应的性能调优参数配置,那么首先考虑的应该是把这个线程池充分利用起来。


如果仅仅是因为异步化之后可以提升服务响应速度,没有达到串行改并行的效果,那么我更加建议使用消息队列。


好了,本文的技术部分就到这里啦。


下面这个环节叫做[荒腔走板],技术文章后面我偶尔会记录、分享点生活相关的事情,和技术毫无关系。我知道看起来很突兀,但是我喜欢,因为这是一个普通博主的生活气息。


荒腔走板



不知道你看完文章之后,有没有产生一个小疑问:最开始部分的 Demo 似乎用处并不大?


是的,我最开始构思的行文结构是是基于 Demo 在源码中找到关于线程池的部分,从而引出其实有一些我们“看不见的线程池”的存在的。


原本周六我是有一整天的时间来写这篇文章,甚至周五晚上还特意把 Demo 搞定,自己调试了一番,该打的断点全部打上,并写完 Demo 那部分之后,我才去睡觉的,想得是第二天早上起来直接就能用。


按照惯例周六睡个懒觉的,早上 11 点才起床,自己慢条斯理的做了一顿午饭,吃完饭已经是下午 1 点多了。


本来想着在沙发上躺一会,结果一躺就是一整个下午。期间也想过起来写一会文章,坐在电脑前又飞快的躺回到沙发上,就是觉得这个事情索然无味,当下的那一刻就想躺着,然后无意识的刷手机,原本是拿来写文章中关于源码的部分的时间就这样浪费了。


像极了高中时的我,周末带大量作业回家,准备来个悬梁刺股,弯道超车,结果变成了一睡一天,捏紧刹车。


高中的时候,时间浪费了是真的可惜。


现在,不一样了。


荒腔走板这张图片,就是我躺在沙发上的时候,别人问我在干什么时随手拍的一张。


我并不为躺了一下午没有干正事而感到惭愧,浪费了的时间,才是属于自己的时间。


很久以前我看到别人在做一些浪费时间的事情的时候,我心里可能会嘀咕几句,劝人惜时。


这两年我不会了,允许自己做自己,允许别人做别人。


作者:why技术
来源:juejin.cn/post/7297980721590272040
收起阅读 »

MyBatis-Plus快速入门指南:零基础学习也能轻松上手

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。那么,MyBatis-Plus到底是什么?又该...
继续阅读 »

在Java开发的世界里,持久层框架的选择对于项目的成功至关重要。今天,我们要聊的主角是MyBatis-Plus——一个增强版的MyBatis,它以其强大的功能、简洁的代码和高效的性能,正在成为越来越多开发者的新宠。

那么,MyBatis-Plus到底是什么?又该如何快速入门呢?让我们一起探索这个强大的工具。

一、MyBatis-Plus简介

1、简介

MyBatis-Plus (opens new window)(简称 MP)是一个 MyBatis (opens new window)的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。

Description

2、特性

无侵入: 只做增强不做改变,引入它不会对现有工程产生影响,如丝般顺滑。

损耗小: 启动即会自动注入基本 CURD,性能基本无损耗,直接面向对象操作,BaseMapper。

强大的 CRUD 操作: 内置通用 Mapper、通用 Service,仅仅通过少量配置即可实现单表大部分 CRUD 操作,更有强大的条件构造器,满足各类使用需求,简单的CRUD操作不用自己编写。

支持 Lambda 形式调用: 通过 Lambda 表达式,方便的编写各类查询条件,无需再担心字段写错。

支持主键自动生成: 支持多达 4 种主键策略(内含分布式唯一 ID 生成器 - Sequence),可自由配置,完美解决主键问题。

支持 ActiveRecord 模式: 支持 ActiveRecord 形式调用,实体类只需继承 Model 类即可进行强大的 CRUD 操作。

支持自定义全局通用操作: 支持全局通用方法注入( Write once, use anywhere )。

内置代码生成器: 采用代码或者 Maven 插件可快速生成 Mapper 、 Model 、 Service 、 Controller 层代码,支持模板引擎,更有超多自定义配置等您来使用(自动生成代码)。

内置分页插件: 基于 MyBatis 物理分页,开发者无需关心具体操作,配置好插件之后,写分页等同于普通 List 查询。

分页插件支持多种数据库: 支持 MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等多种数据库。

内置性能分析插件: 可输出 SQL 语句以及其执行时间,建议开发测试时启用该功能,能快速揪出慢查询。

内置全局拦截插件: 提供全表 delete 、 update 操作智能分析阻断,也可自定义拦截规则,预防误操作。

3、框架结构

Description

二、快速入门

1.开发环境

2.创建数据库和表

1)创建表单

CREATE DATABASE `mp_study` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;
use `mp_study`;
CREATE TABLE `user` (
`id` bigint(20) NOT NULL COMMENT '主键ID',
`name` varchar(30) DEFAULT NULL COMMENT '姓名',
`age` int(11) DEFAULT NULL COMMENT '年龄',
`email` varchar(50) DEFAULT NULL COMMENT '邮箱',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2)添加数据

INSERT INTO user (id, name, age, email) VALUES
(1, 'Jone', 18, 'test1@baomidou.com'),
(2, 'Jack', 20, 'test2@baomidou.com'),
(3, 'Tom', 28, 'test3@baomidou.com'),
(4, 'Sandy', 21, 'test4@baomidou.com'),
(5, 'Billie', 24, 'test5@baomidou.com');

3. 创建SpringBoot工程

1)初始化工程

2)导入依赖

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

4. 编写代码

1)配置application.yml

# DataSource Config
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/mybatis_plus?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
username: root
password: 1234

2)启动类

在Spring Boot启动类中添加@MapperScan注解,扫描mapper包

@MapperScan("cn.frozenpenguin.mapper")
@SpringBootApplication
public class MybatisPlusStudyApplication {

public static void main(String[] args) {
SpringApplication.run(MybatisPlusStudyApplication.class, args);
}
}

3)添加实体类

@Data//lombok注解
public class User {
private Long id;
private String name;
private Integer age;
private String email;
}

4)添加mapper
BaseMapper是MyBatis-Plus提供的模板mapper,其中包含了基本的CRUD方法,泛型为操作的实体类型

public interface UserMapper extends BaseMapper<User> {
}

5)测试

@Autowired
private UserMapper userMapper;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

结果
Description
注意:

IDEA在 userMapper 处报错,因为找不到注入的对象,因为类是动态创建的,但是程序可以正确执行。为了避免报错,可以在mapper接口上添加 @Repository注解。

6)添加日志

我们所有的sql现在是不可见的,我们希望知道它是怎么执行的,所以我们必须要看日志!

在application.yml中配置日志输出

# 配置日志
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations:

三、基本CRUD

1.插入

 @Test
void insert()
User user = new User(null, "lisi", 2, "aaa@qq.com");
int insert = userMapper.insert(user);
System.out.println("受影响行数"+insert);
//1511332162436071425
System.out.println(user.getId());
}

id设置为null,却插入了1511332162436071425,这是因为MyBatis-Plus在实现插入数据时,会默认基于雪花算法的策略生成id。

2.删除

1)通过id删除记录

@Test
void testDeleteById(){
//DELETE FROM user WHERE id=?
int result = userMapper.deleteById(1);
System.out.println("受影响行数:"+result);
}
  1. 通过id批量删除记录
@Test
void testDeleteBatchIds(){
//DELETE FROM user WHERE id IN ( ? , ? , ? )
int result = userMapper.deleteBatchIds(ids);
System.out.println("受影响行数:"+result);
}
  1. 通过map条件删除记录
@Test
void testDeleteByMap(){
//DELETE FROM user WHERE name = ? AND age = ?
Map<String,Object> map=new HashMap<>();
map.put("age",12);
map.put("name","lisi");
int result = userMapper.deleteByMap(map);
System.out.println("受影响行数:"+result);
}

3. 修改

@Test
void testUpdateById(){
//SELECT id,name,age,email FROM user WHERE id=?
User user = new User(10L, "hello", 12, null);
int result = userMapper.updateById(user);
//注意:updateById参数是一个对象
System.out.println("受影响行数:"+result);
}

4.自动填充

  • 创建时间、修改时间!这些个操作都是自动化完成的,我们不希望手动更新!

  • 阿里巴巴开发手册:所有的数据库表:gmt_create、gmr_modified、几乎所有的表都要配置上!而且需要自动化!

方式一:数据库级别(工作中不允许修改数据库)

1)在表中新增字段 create_time, update_time;

Description

2)再次测试插入方法,我们需要先把实体类同步!

3)再次更新查看结果即可。

Description

方式二:代码级别

  • 删除数据库的默认值,更新操作

  • 实体类的字段属性上需要加注解

@TableField(fill = FieldFill.INSERT)
private Date createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
  • 编写处理器处理注解
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐使用)
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
}


@Override
public void updateFill(MetaObject metaObject) {
// 起始版本 3.3.0(推荐)
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
  • 测试插入

  • 测试更新、观察时间即可

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,

点击这里,立即开始你的学习之旅!

5.查询

  • 与查询基本一致;

  • 根据id查询用户信息;

  • 根据多个id查询多个用户信息;

  • 通过map条件查询用户信息;

  • 查询所有数据;

@Test
void test01(){
List<User> users = userMapper.selectList(null);
for (User user : users) {
System.out.println(user);
}
}

通过观察BaseMapper中的方法,大多方法中都有Wrapper类型的形参,此为条件构造器,可针 对于SQL语句设置不同的条件,若没有条件,则可以为该形参赋值null,即查询(删除/修改)所有数据。

6.通用Service

说明:

  • 通用 Service CRUD 封装IService接口,进一步封装 CRUD;

  • 采用 get 查询单行;

  • remove 删除;

  • list 查询集合;

  • page 分页;

  • 前缀命名方式区分 Mapper 层避免混淆;

  • 泛型 T 为任意实体对象;

  • 建议如果存在自定义通用 Service 方法的可能,请创建自己的 IBaseService 继承 Mybatis-Plus 提供的基类;

  • 官网地址:https://baomidou.com/pages/49cc81/#service-crud-%E6%8E%A5%E5%8F%A3。

1)IService

MyBatis-Plus中有一个接口 IService和其实现类 ServiceImpl,封装了常见的业务层逻辑 详情查看源码IService和ServiceImpl。

2)创建Service接口和实现

/**
* UserService继承IService模板提供的基础功能
*/
public interface UserService extends IService<User> {
}
/**
* ServiceImpl实现了IService,提供了IService中基础功能的实现
* 若ServiceImpl无法满足业务需求,则可以使用自定的UserService定义方法,并在实现类中实现
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
}

3)测试查询记录数

@Test
void testGetCount(){
long count = userService.count();
System.out.println("总记录数:" + count);
}

4)测试批量插入

@Test
void testSaveBatch(){
// SQL长度有限制,海量数据插入单条SQL无法实行,
// 因此MP将批量插入放在了通用Service中实现,而不是通用Mapper
ArrayList<User> users = new ArrayList<>();
for (int i = 0; i < 5; i++) {
User user = new User();
user.setName("lyl"+i);
user.setAge(20+i);
users.add(user);
}
//SQL:INSERT INTO t_user ( username, age ) VALUES ( ?, ? )
userService.saveBatch(users);
}

原理:先把user对象存到list(存在内存中),然后直接save集合list中的所有user。

MyBatis-Plus作为MyBatis的增强版,不仅继承了MyBatis的所有优点,还在此基础上做了大量的改进和扩展。它的出现,无疑为Java开发者提供了一个更为强大、便捷的数据操作工具。

技术的世界总是在不断进步,而我们作为开发者,也需要不断学习新的工具和技术。MyBatis-Plus正是这样一把钥匙,它能打开高效数据操作的大门。希望本文能帮助您快速入门MyBatis-Plus!

收起阅读 »

请立即停止编写 Dockerfiles 并使用 docker init

本文翻译自 medium 论坛,原文链接:medium.com/@akhilesh-m… , 原文作者: Akhilesh Mishra 您是那种觉得编写 Dockerfile 和 docker-compose.yml 文件很痛苦的人之一吗? 我承认,我就是...
继续阅读 »

本文翻译自 medium 论坛,原文链接:medium.com/@akhilesh-m… , 原文作者:

Akhilesh Mishra


您是那种觉得编写 Dockerfile 和 docker-compose.yml 文件很痛苦的人之一吗?



我承认,我就是其中之一。



我总是想知道我是否遵循了 Dockerfile、 docker-compose 文件的最佳编写实践,我害怕在不知不觉中引入了安全漏洞。


但是现在,我不必再担心这个问题了,感谢 Docker 的优秀开发人员,他们结合了生成式人工智能,创建了一个 CLI 实用工具 — docker init。


介绍 docker init


微信截图_20240224145630.png


几天前,Docker 推出了 docker init 的通用版本。我已经尝试过,发现它非常有用,迫不及待地想在日常生活中使用它。


什么是 docker init?


docker init 是一个命令行应用程序,可帮助初始化项目中的 Docker 资源。它根据项目的要求创建 Dockerfiles、docker-compose 文件和 .dockerignore 文件。


这简化了为项目配置 Docker 的过程,节省时间并降低复杂性。



最新版本的 docker init 支持 Go、Python、Node.js、Rust、ASP.NET、PHP 和 Java。目前它只能于 Docker Desktop 一起使用,也就是说大家目前在 Linux 系统中是无法使用 docker init 的。



如何使用 docker init?


使用 docker init 很简单,只需几个简单的步骤。首先,转到您要在其中设置 Docker 资源的项目目录。


举个例子,我来创建一个基本的 Flask 应用程序。


一、创建 app.py 以及 requirements.txt


touch app.py requirements.txt

将以下代码复制到相应文件中


# app.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_docker():
return '<h1> hello world </h1'

if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

# requirements.txt
Flask

二、使用 docker init 初始化


docker init 将扫描您的项目并要求您确认并选择最适合您的应用程序的模板。选择模板后,docker init 会要求您提供一些特定于项目的信息,自动为您的项目生成必要的 Docker 资源。


现在让我们来执行 docker init。


docker init

出现如下结果,



接下来要做的就是选择应用程序平台,在我们的示例中,我们使用 python。它将建议您的项目的推荐值,例如 Python 版本、端口、入口点命令。



您可以选择默认值或提供所需的值,它将创建您的 docker 配置文件以及动态运行应用程序的说明。



让我们来看看这个自动生成的配置是什么样子。


三、生成 Dockerfile 文件


# syntax=docker/dockerfile:1

# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/engine/reference/builder/

ARG PYTHON_VERSION=3.11.7
FROM python:${PYTHON_VERSION}-slim as base

# Prevents Python from writing pyc files.
ENV PYTHONDONTWRITEBYTECODE=1

# Keeps Python from buffering stdout and stderr to avoid situations where
# the application crashes without emitting any logs due to buffering.
ENV PYTHONUNBUFFERED=1

WORKDIR /app

# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser

# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
# Leverage a bind mount to requirements.txt to avoid having to copy them int0
# int0 this layer.
RUN --mount=type=cache,target=/root/.cache/pip \
--mount=type=bind,source=requirements.txt,target=requirements.txt \
python -m pip install -r requirements.txt

# Switch to the non-privileged user to run the application.
USER appuser

# Copy the source code int0 the container.
COPY . .

# Expose the port that the application listens on.
EXPOSE 5000

# Run the application.
CMD gunicorn 'app:app' --bind=0.0.0.0:5000

看看它,它写了一个比我更好的 Dockerfile。



它遵循人们在所有 Linkedin 和 Medium 帖子中不断告诉我们的所有性能和安全最佳实践。



docker-compose.yml



它编写了 docker-compose 配置来运行应用程序。由于我们的应用程序不包含与数据库的任何连接,因此它注释掉了数据库容器可能需要的代码。


如果您想在 Flask 应用程序中使用数据库,请从 docker-compose 文件中取消注释 db 服务配置,创建一个包含机密的本地文件,然后运行该应用程序。它还为我们生成了 .dockerignore 文件。


为什么使用 docker init?


docker init 使 Docker 化变得轻而易举,特别是对于 Docker 新手来说。它消除了编写 Dockerfile 和其他配置文件的手动任务,从而节省时间并最大限度地减少错误。


它使用模板根据您的应用程序类型自定义 Docker 设置,同时遵循行业最佳实践。


总结一下


总而言之,docker init 完成了上面这一切。



  • 它可以编写比这里 90% 的孩子更好的 Docker 配置。

  • 像书呆子一样遵循最佳实践。

  • 当安全人员的工具生成包含数百个您从未想过存在的漏洞的报告时,可以节省时间、精力和来自安全人员的讽刺评论。


最后需要说明的是,就像任何其他基于人工智能的工具一样,这个工具也不完美。不要盲目相信它生成的配置。我建议您在使用配置之前再次检查下配置。



如果觉得这篇文章翻译不错的话,不妨点赞加关注,我会更新更多技术干货、项目教学、经验分享的文章。



作者:程序员wayn
来源:juejin.cn/post/7338717224435531826
收起阅读 »

不可不知的Redis秘籍:事务命令全攻略!

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升...
继续阅读 »

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。

今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升我们的数据操作效率和安全性。

一、Redis事务的概念

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

Description

总结来说: redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis事务没有隔离级别的概念

批量操作在发送 EXEC 命令前被放入队列缓存,并不会被实际执行,也就不存在事务内的查询要看到事务里的更新,事务外查询不能看到。

Redis不保证原子性

Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。

redis事务的执行阶段

  • 开始事务(multi)。
  • 命令入队。
  • 执行事务(exec)

Description

二、Redis事务优缺点

对于Redis事务的概念我们已经有了基本的了解,下面我们再来看看它都有哪些优缺点。

优点:

  • 一次性按顺序执行多个Redis命令,不受其他客户端命令请求影响;

  • 事务中的命令要么都执行(命令间执行失败互相不影响),要么都不执行(比如中间有命令语法错误);

缺点:

  • 事务执行时,不能保证原子性;

  • 命令入队每次都需要和服务器进行交互,增加带宽;

注意:

  • 当事务中命令语法使用错误时,最终会导致事务执行不成功,即事务内所有命令都不执行;

  • 当事务中命令知识逻辑错误,就比如给字符串做加减乘除操作时,只能在执行过程中发现错误,这种事务执行中失败的命令不影响其他命令的执行。

三、Redis事务相关命令

Redis事务可以通过一系列命令来执行多个操作,并确保这些操作可以原子性地执行。以下是Redis事务的相关命令及其作用:

MULTI: 开启一个事务。在调用此命令后,Redis 会将后续的命令逐个放入队列中,直到接收到 EXEC 命令为止。

EXEC: 执行事务中的所有操作命令。一旦调用 EXEC 命令,Redis 会原子性地执行队列中的所有命令。

DISCARD: 取消事务,放弃执行事务块中的所有命令。如果不想继续执行事务中的操作,可以使用 DISCARD 命令来清除当前事务队列。

WATCH: 监视一个或多个键,如果在事务执行之前这些键被其他命令所改动,那么事务将会被打断。

UNWATCH: 取消所有由 WATCH 命令监视的键。如果不想继续监视某些键,可以使用 UNWATCH 命令来取消监视。

需要注意的是,在事务执行过程中,其他客户端提交的命令请求不会插入到事务执行命令序列中,这保证了事务的隔离性。同时,Redis 事务提供了批量操作缓存的功能,即在发送 EXEC 命令前,所有操作都会被放入队列缓存。

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,点击这里,立即开始你的学习之旅!

四、Redis事务的使用

使用Redis事务的步骤如下:

  • 使用MULTI命令开启一个事务。

  • 在事务中执行需要的命令,如SET、GET等。

  • 使用EXEC命令提交事务,将事务中的命令一次性发送给Redis服务器执行。

  • 如果需要取消事务,可以使用DISCARD命令。

Description

下面通过一些示例来讲解一下这些命令的使用方法:

1、正常执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa AA
QUEUED
192.168.xxx.21:6379> set bb BB
QUEUED
192.168.xxx.21:6379> set cc CC
QUEUED
192.168.xxx.21:6379> set dd DD
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
3) OK
4) OK
192.168.xxx.21:6379> get aa
"AA"

首先,通过执行multi命令开始一个事务块。然后,依次执行了四个set命令,将键"aa"、“bb”、“cc"和"dd"分别设置为对应的值"AA”、“BB”、“CC"和"DD”。

每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。执行结果为每个命令的返回值,即"OK"。最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。

2、取消事务

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> set ee EE
QUEUED
192.168.xxx.21:6379> discard
OK
192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> get ee
(nil)
192.168.xxx.21:6379>

示例代码中,首先,通过执行multi命令开始一个事务块。然后,依次执行了两个set命令,将键"aa"设置为值"11",将键"ee"设置为值"EE"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行discard命令来取消事务,放弃执行事务块内的所有命令。执行结果为"OK"。

最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。而执行get ee命令获取键"ee"的值时,由于之前已经取消了事务,所以返回结果为"(nil)",表示该键不存在。

3、事务队列中存在命令错误

如果在事务队列中存在命令性错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 22
QUEUED
192.168.xxx.21:6379> set bb 33
QUEUED
192.168.xxx.21:6379> setq cc 44
(error) ERR unknown command 'setq'
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
192.168.xxx.21:6379> get ff
(nil)
192.168.xxx.21:6379> get bb
"BB"
192.168.xxx.21:6379>

首先,通过执行multi命令开始一个事务块。然后,依次执行了三个set命令,将键"aa"设置为值"22",将键"bb"设置为值"33",将键"cc"设置为值"44"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

然而,在执行第三个set命令时,出现了错误。因为Redis中并没有名为"setq"的命令,所以返回结果为"(error) ERR unknown command ‘setq’"。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。由于之前已经出现了错误,导致事务被中断,所以执行结果为"(error) EXECABORT Transaction discarded because of previous errors."。

最后,通过执行get ff命令获取键"ff"的值时,由于事务被中断,所以返回结果为"(nil)“,表示该键不存在。而执行get bb命令获取键"bb"的值时,由于事务被中断,所以返回结果为"BB”。

4、事务队列中存在语法错误

如果在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常。

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> incr aa
QUEUED
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> set bb 22
QUEUED
192.168.xxx.21:6379> exec
1) (error) ERR value is not an integer or out of range
2) OK
3) OK
192.168.xxx.21:6379> get bb
"22"
192.168.xxx.21:6379> get ff
"FF"
192.168.xxx.21:6379>

错误原因:字符串不能累加1

5、watch监控

watch 命令可以监控一个或多个键,一旦有其中一个键被修改(被删除),后面的事务就不会执行了。监控一直持续到 EXEC 命令(事务中的命令是在exec之后才执行的,所以在multi命令后可以修改watch监控的键值)

假设我们通过watch命令在事务执行之前监控了多个Keys,倘若在watch之后有任何Key的值发生了变化,exec命令执行的事务都将被放弃,同时返回Null multi-bulk应答以通知调用者事务执行失败。

(1)、执行watch,不执行multi、exec

192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> watch aa
OK
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(2)、执行 watch 命令,通知执行 MULTI、exec

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379>

(3)、exec 执行之后,会自动执行 UNWatch 命令,撤销监听操作

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(4) 、unwatch撤销监听

192.168.xxx.21:6379> get bb
"BBB"
192.168.xxx.21:6379> watch bb
OK
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> unwatch
QUEUED
192.168.xxx.21:6379> set bb 222
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
192.168.xxx.21:6379> get bb
"222"
192.168.xxx.21:6379>

以上就是Redis事务的概念及相关命令的使用,Redis事务是一个非常强大的工具,它可以帮助我们在处理数据的时候保持数据的一致性和完整性。通过使用Redis事务,可以让我们的数据操作更高效、更安全。

希望这篇文章能够帮助你更好地理解和使用Redis事务!

收起阅读 »

应用容器化后为什么性能下降这么多?

1. 背景 随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。 2. 压测结果 2.1 容器化之...
继续阅读 »

1. 背景


随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。


2. 压测结果


2.1 容器化之前的表现


应用部署在虚拟机下,我们使用wrk工具进行压测,压测结果如下:


image.png


从压测结果看,平均RT1.68msqps716/s\color{red}{平均RT为1.68ms,qps为716/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.2 容器化后的表现


使用wrk工具进行压测,结果如下:
image.png


从压测结果看,平均RT2.11msqps554/s\color{red}{平均RT为2.11ms,qps为554/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.3 性能对比结果


性能对比虚拟机容器
RT1.68ms2.11ms
QPS716/s554/s


总体性能下降:RT(25%)、QPS(29%)



3. 原因分析


3.1 架构差异


由于应用在容器化后整体架构的不同、访问路径的不同,将可能导致应用容器化后性能的下降,于是我们先来分析下两者架构的区别。我们使用k8s作为容器编排基础设施,网络插件使用calico的ipip模式,整体架构如下所示。


x3.png


这里需要说明,虽然使用calico的ipip模式,由于pod的访问为service的nodePort模式,所以不会走tunl0网卡,而是从eth0经过iptables后,通过路由到calico的calixxx接口,最后到pod。


3.2性能分析


在上面压测结果的图中,我们容器化后,cpu的软中断si使用率明显高于原先虚拟机的si使用率,所以我们使用perf继续分析下热点函数。


image.png
为了进一步验证是否是软中断的影响,我们使用perf进一步统计软中断的次数。


image.png



我们发现容器化后比原先软中断多了14%,到这里,我们能基本得出结论,应用容器化以后,需要更多的软中断的网络通信导致了性能的下降。



3.3 软中断原因


由于容器化后,容器和宿主机在不同的网络namespace,数据需要在容器的namespace和host namespace之间相互通信,使得不同namespace的两个虚拟设备相互通信的一对设备为veth pair,可以使用ip link命令创建,对应上面架构图中红色框内的两个设备,也就是calico创建的calixxx和容器内的eth0。我们再来看下veth设备发送数据的过程


static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
...
if (likely(veth_forward_skb(rcv, skb, rq, rcv_xdp)
...
}

static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb,
struct veth_rq *rq, bool xdp)
{
return __dev_forward_skb(dev, skb) ?: xdp ?
veth_xdp_rx(rq, skb) :
netif_rx(skb);//中断处理
}


/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
list_add_tail(&napi->poll_list, &sd->poll_list);
//发起软中断
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

通过虚拟的veth发送数据和真实的物理接口没有区别,都需要完整的走一遍内核协议栈,从代码分析调用链路为veth_xmit -> veth_forward_skb -> netif_rx -> __raise_softirq_irqoff,veth的数据发送接收最后会使用软中断的方式,这也刚好解释了容器化以后为什么会有更多的软中断,也找到了性能下降的原因。


4. 优化策略


原来我们使用calico的ipip模式,它是一种overlay的网络方案,容器和宿主机之间通过veth pair进行通信存在性能损耗,虽然calico可以通过BGP,在三层通过路由的方式实现underlay的网络通信,但还是不能避免veth pari带来的性能损耗,针对性能敏感的应用,那么有没有其他underly的网络方案来保障网络性能呢?那就是macvlan/ipvlan模式,我们以ipvlan为例稍微展开讲讲。


4.1 ipvlan L2 模式


IPvlan和传统Linux网桥隔离的技术方案有些区别,它直接使用linux以太网的接口或子接口相关联,这样使得整个发送路径变短,并且没有软中断的影响,从而性能更优。如下图所示:


ipvlan l2 mode


上图是ipvlan L2模式的通信模型,可以看出container直接使用host eth0发送数据,可以有效减小发送路径,提升发送性能。


4.2 ipvlan L3 模式


ipvlan L3模式,宿主机充当路由器的角色,实现容器跨网段的访问,如下图所示:


ipvlan L3 mode


4.3 Cilium


除了使用macvlan/ipvlan提升网络性能外,我们还可以使用Cilium来提升性能,Cilium为云原生提供了网络、可观测性、网络安全等解决方案,同时它是一个高性能的网络CNI插件,高性能的原因是优化了数据发送的路径,减少了iptables开销,如下图所示:


cilium netwok


虽然calico也支持ebpf,但是通过benchmark的对比,Cilium性能更好,高性能名副其实,接下来我们来看看官网公布的一些benchmark的数据,我们只取其中一部分来分析,如下图:


xxxx2
xxxx3


无论从QPS和CPU使用率上Cilium都拥有更强的性能。


5. 总结


容器化带来了敏捷、效率、资源利用率的提升、环境的一致性等等优点的同时,也使得整体的系统复杂度提升一个等级,特别是网络问题,容器化使得整个数据发送路径变长,排查难度增大。不过现在很多网络插件也提供了很多可观测性的能力,帮助我们定位问题。


我们还是需要从实际业务场景出发,针对容器化后性能、安全、问题排查难度增大等问题,通过优化架构,增强基础设施建设才能让我们在云原生的路上越走越远。


最后,感谢大家观看,也希望和我讨论云原生过程中遇到的问题。


5. 参考资料


docs.docker.com/network/dri…


cilium.io/blog/2021/0…


作者:云之舞者
来源:juejin.cn/post/7268663683881828413
收起阅读 »

记一种不错的缓存设计思路

之前与同事讨论接口性能问题时听他介绍了一种缓存设计思路,觉得不错,做个记录供以后参考。 场景 假设有个以下格式的接口: GET /api?keys={key1,key2,key3,...}&types={1,2,3,...} 其中 keys 是业务...
继续阅读 »

之前与同事讨论接口性能问题时听他介绍了一种缓存设计思路,觉得不错,做个记录供以后参考。


场景


假设有个以下格式的接口:


GET /api?keys={key1,key2,key3,...}&types={1,2,3,...}


其中 keys 是业务主键列表,types 是想要取到的信息的类型。


请求该接口需要返回业务主键列表对应的业务对象列表,对象里需要包含指定类型的信息。


业务主键可能的取值较多,千万量级,type 取值范围为 1-10,可以任意组合,每种 type 对应到数据库是 1-N 张表,示意:


redis-cache-design.drawio.png


现在设想这个接口遇到了性能瓶颈,打算添加 Redis 缓存来改善响应速度,应该如何设计?


设计思路


方案一:


最简单粗暴的方法是直接使用请求的所有参数作为缓存 key,请求的返回内容为 value。


方案二:


如果稍做一下思考,可能就会想到文首我提到的觉得不错的思路了:



  1. 使用 业务主键:表名 作为缓存 key,表名里对应的该业务主键的记录作为 value;

  2. 查询时,先根据查询参数 keys,以及 types 对应的表,得到所有 key1:tb_1_1key1:tb_1_2 这样的组合,使用 Redis 的 mget 命令,批量取到所有缓存中存在的信息,剩下没有命中的,批量到数据库里查询到结果,并放入缓存;

  3. 在某个表的数据有更新时,只需刷新 涉及业务主键:该表名 的缓存,或令其失效即可。


小结


在以上两种方案之间做评估和选择,考虑几个方面:



  • 缓存命中率;

  • 缓存数量、占用空间大小;

  • 刷新缓存是否方便;


稍作思考和计算,就会发现此场景下方案二的优势。


另外,就是需要根据实际业务场景,如业务对象复杂度、读写次数比等,来评估合适的缓存数据的粒度和层次,是对应到某一级组合后的业务对象(缓存值对应存储 + 部分逻辑),还是最基本的数据库表/字段(存储的归存储,逻辑的归逻辑)。


作者:mzlogin
来源:juejin.cn/post/7271597656118394899
收起阅读 »

HTTP请求头中的Authorization

当使用HTTP请求中的Authorization头时,表示传入的是认证信息。具体的认证类型由凭证前缀指明。以下是Authorization头中常见的几种认证机制: 基本认证(Basic Authentication): Authorization: Basi...
继续阅读 »

当使用HTTP请求中的Authorization头时,表示传入的是认证信息。具体的认证类型由凭证前缀指明。以下是Authorization头中常见的几种认证机制:



  1. 基本认证(Basic Authentication):


    Authorization: Basic base64(用户名:密码)

    这是最常见的一种,涉及将用户名和密码以base64格式编码并与请求一起发送。需要注意,Basic 后面有空格, 未使用HTTPS时基本认证不够安全
    实际使用例子,比如:


    curl -u "admin:P@88w0rd" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users

    curl -u “username:password” 就相当于在请求的请求头中添加keyAuthorizationvalueadmin:P@88w0rd,这是一种认证方式。
    对账号密码进行base64编码之后


    echo -n "admin:P@88w0rd" | base64

    得到:YWRtaW46UEA4OHcwcmQ=
    ,上方的curl也可以写成:


    curl -H "Authorization: Basic YWRtaW46UEA4OHcwcmQ=" -H "Accept:  application/json" http://localhost:8090/api/v1alpha1/users


  2. Bearer令牌(Bearer Token):


    Authorization: Bearer <令牌>

    这通常与OAuth 2.0一起使用。<令牌>通常是通过单独的认证过程获取的长寿命访问令牌。


  3. 摘要认证(Digest Authentication):


    Authorization: Digest username="用户名", realm="领域", nonce="随机数", uri="URI", response="响应", opaque="opaque", qop=auth, nc=00000001, cnonce="cnonce"

    摘要认证比基本认证更安全,涉及挑战-响应机制来验证客户端。挑战-响应机制(Challenge-Response Mechanism,在这种机制中,服务器通过向客户端发送一个随机的挑战(challenge),并期望客户端使用其凭据(通常是密码)生成一个对应的响应(response)来证明其身份,服务端收到响应后验证身份)


  4. API密钥(API Key):


    Authorization: ApiKey <API密钥>

    API密钥通常用于API请求中的身份验证。密钥包含在Authorization头中。


  5. Bearer令牌(JWT):


    Authorization: Bearer eyJhbGciOiJIUzI1NiIsIn...

    JSON Web Tokens(JWT)通常在现代身份验证系统中使用。令牌包含在Bearer方案中。


  6. 自定义方案(Custom Schemes):
    一些应用程序或服务可能定义了自己的自定义认证方案。例如:


    Authorization: CustomScheme 自定义数据



以上的使用的scheme,如BasicBearer,Digest,ApiKey是约定俗成的,大家都这样使用,具体认证类型取决于服务器的要求和实现的协议,针对自己的业务也可以自定义scheme。也可以参考正在与之交互的服务或API的文档,以确定Authorization头的正确格式。


作者:星夜晚晚
来源:juejin.cn/post/7329573746464718857
收起阅读 »

双token和无感刷新token(简单写法,一文说明白,不墨迹)

为什么有这篇小作文?最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子项目构成后端部分:使用golang的gin框架起的服务前端部分:vue+elementu...
继续阅读 »

为什么有这篇小作文?

最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子

项目构成

  • 后端部分:使用golang的gin框架起的服务
  • 前端部分:vue+elementui

先说后端部分,后端逻辑相对前端简单点,关键两步

  1. 登陆接口生成双token
"github.com/dgrijalva/jwt-go"
func (this UserController) DoLogin(ctx *gin.Context) {
username := ctx.Request.FormValue("username")
passWord := ctx.Request.FormValue("password")
passMd5 := middlewares.CreateMD5(passWord)
expireTime := time.Now().Add(10 * time.Second).Unix() //token过期时间10秒,主要是测试方便
refreshTime := time.Now().Add(20 * time.Second).Unix() //刷新的时间限制,超过20秒重新登录
user := modules.User{}
err := modules.DB.Model(&modules.User{}).Where("username = ? AND password = ?", username, passMd5).Find(&user).Error
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "用户名或密码错误",
})
} else {
println("expireTime", string(rune(expireTime)))
myClaims := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(200, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "登录成功",
"success": true,
"token": tokenStr,//数据请求的token
"refreshToken": tokenStrRefresh,//刷新token用的
})
}
}
}
  1. 刷新token的方法
func (this UserController) RefrshToken(ctx *gin.Context) {
tokenData := ctx.Request.Header.Get("Authorization") //这里是个关键点,刷新token时也要带上token,不过这里是前端传的refreshToken
if tokenData == "" {
ctx.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
ctx.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
_, claims, err := middlewares.ParseToken(tokenStr)
expireTime := time.Now().Add(10 * time.Second).Unix()
refreshTime := time.Now().Add(20 * time.Second).Unix()
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "token传入错误",
})
} else {
myClaims := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(400, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "刷新token成功",
"success": true,
"token": tokenStr,
"refreshToken": tokenStrRefresh,
})
}
}
}
  1. 路由中间件里验证token
package middlewares

import (
"strings"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

type MyClaims struct {
Uid int
jwt.StandardClaims
}

func AuthMiddleWare(c *gin.Context) {
tokenData := c.Request.Header.Get("Authorization")
if tokenData == "" {
c.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
c.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
token, _, err := ParseToken(tokenStr)
if err != nil || !token.Valid {
// 这里我感觉觉是个关键点,我看别人写的,过期了返回401,但是前端的axios的响应拦截器里捕获不到,所以我用201状态码,
c.JSON(201, gin.H{
"message": "token已过期",
"success": false,
})
c.Abort()
return
} else {
c.Next()
}
}

func ParseToken(tokenStr string) (*jwt.Token, *MyClaims, error) {
jwtKey := []byte("lyf123456")
// 解析token
myClaims := &MyClaims{}
token, err := jwt.ParseWithClaims(tokenStr, myClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
return token, myClaims, err
}

总结一下:后端部分三步,1.登陆时生成双token,2,路由中间件里验证token,过期时返回201状态码(201是我私人定的,并不是行业标准)。3,刷新token的方法里也和登陆接口一样返回双token

前端部分

前端部分在axios封装时候加拦截器判断token是否过期,我这里跟别人写的最大的不同点是:我创建了两个axios对象,一个正常数据请求用(server),另一个专门刷新token用(serverRefreshToken),这样写的好处是省去了易错的判断逻辑

import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
//数据请求用
const server=axios.create({
baseURL:'/shopApi',
timeout:5000
})
// 刷新token专用
const serverRefreshToken=axios.create({
baseURL:'/shopApi',
timeout:5000
})
//获取新token的方法
async function getNewToken(){
let res=await serverRefreshToken.request({
url:`/admin/refresh`,
method:"post",
})
if(res.status==200){
sessionStorage.setItem("token",res.data.token)
sessionStorage.setItem("refreshToken",res.data.refreshToken)
return true
}else{
ElMessage.error(res.data.message)
router.push('/login')
return false
}
}
//这里是正常获取数据用的请求拦截器,主要作用是给所有请求的请求头里加上token
server.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("token")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
//这里是正常获取数据用的响应拦截器,正常数据请求都是200状态码,当拦截到201状态码时,代表token过期了,
server.interceptors.response.use(async(res)=>{
if(res.status==201){
//获取新token
let bl=await getNewToken()
if(bl){
//获取成功新token之后,把刚才token过期拦截到的请求重新发一遍,获取到数据之后把res覆盖掉
//这里是个关键点,下边这行代码里的第二个res是token过期后被拦截的那个请求,config里是该请求的详细信息,重新请求后返回的是第一个res,把失败的res覆盖掉,这里有点绕,文字不好表达,
res=await server.request(res.config)
}
}
return res
},error=>{
if(error.response.status==500||error.response.status==401||error.response.status==400){
router.push('/login')
ElMessage.error(error.response.data.message)
Promise.reject(error)
}

})
//这里是刷新token专用的axios对象,他的作用是给请求加上刷新token专用的refreshToken
serverRefreshToken.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("refreshToken")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
export default server

总结一下,前端部分:1,正常数据请求和刷新token用的请求分开了,各司其职。省去复杂的判断。2,获取新的token和refreshToken后更新原来旧的token和refreshToken。(完结)


作者:锋行天下
来源:juejin.cn/post/7337876697427148811

为什么有这篇小作文?

最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子

项目构成

  • 后端部分:使用golang的gin框架起的服务
  • 前端部分:vue+elementui

先说后端部分,后端逻辑相对前端简单点,关键两步

  1. 登陆接口生成双token
"github.com/dgrijalva/jwt-go"
func (this UserController) DoLogin(ctx *gin.Context) {
username := ctx.Request.FormValue("username")
passWord := ctx.Request.FormValue("password")
passMd5 := middlewares.CreateMD5(passWord)
expireTime := time.Now().Add(10 * time.Second).Unix() //token过期时间10秒,主要是测试方便
refreshTime := time.Now().Add(20 * time.Second).Unix() //刷新的时间限制,超过20秒重新登录
user := modules.User{}
err := modules.DB.Model(&modules.User{}).Where("username = ? AND password = ?", username, passMd5).Find(&user).Error
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "用户名或密码错误",
})
} else {
println("expireTime", string(rune(expireTime)))
myClaims := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(200, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "登录成功",
"success": true,
"token": tokenStr,//数据请求的token
"refreshToken": tokenStrRefresh,//刷新token用的
})
}
}
}
  1. 刷新token的方法
func (this UserController) RefrshToken(ctx *gin.Context) {
tokenData := ctx.Request.Header.Get("Authorization") //这里是个关键点,刷新token时也要带上token,不过这里是前端传的refreshToken
if tokenData == "" {
ctx.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
ctx.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
_, claims, err := middlewares.ParseToken(tokenStr)
expireTime := time.Now().Add(10 * time.Second).Unix()
refreshTime := time.Now().Add(20 * time.Second).Unix()
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "token传入错误",
})
} else {
myClaims := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(400, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "刷新token成功",
"success": true,
"token": tokenStr,
"refreshToken": tokenStrRefresh,
})
}
}
}
  1. 路由中间件里验证token
package middlewares

import (
"strings"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

type MyClaims struct {
Uid int
jwt.StandardClaims
}

func AuthMiddleWare(c *gin.Context) {
tokenData := c.Request.Header.Get("Authorization")
if tokenData == "" {
c.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
c.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
token, _, err := ParseToken(tokenStr)
if err != nil || !token.Valid {
// 这里我感觉觉是个关键点,我看别人写的,过期了返回401,但是前端的axios的响应拦截器里捕获不到,所以我用201状态码,
c.JSON(201, gin.H{
"message": "token已过期",
"success": false,
})
c.Abort()
return
} else {
c.Next()
}
}

func ParseToken(tokenStr string) (*jwt.Token, *MyClaims, error) {
jwtKey := []byte("lyf123456")
// 解析token
myClaims := &MyClaims{}
token, err := jwt.ParseWithClaims(tokenStr, myClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
return token, myClaims, err
}

总结一下:后端部分三步,1.登陆时生成双token,2,路由中间件里验证token,过期时返回201状态码(201是我私人定的,并不是行业标准)。3,刷新token的方法里也和登陆接口一样返回双token

前端部分

前端部分在axios封装时候加拦截器判断token是否过期,我这里跟别人写的最大的不同点是:我创建了两个axios对象,一个正常数据请求用(server),另一个专门刷新token用(serverRefreshToken),这样写的好处是省去了易错的判断逻辑

import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
//数据请求用
const server=axios.create({
baseURL:'/shopApi',
timeout:5000
})
// 刷新token专用
const serverRefreshToken=axios.create({
baseURL:'/shopApi',
timeout:5000
})
//获取新token的方法
async function getNewToken(){
let res=await serverRefreshToken.request({
url:`/admin/refresh`,
method:"post",
})
if(res.status==200){
sessionStorage.setItem("token",res.data.token)
sessionStorage.setItem("refreshToken",res.data.refreshToken)
return true
}else{
ElMessage.error(res.data.message)
router.push('/login')
return false
}
}
//这里是正常获取数据用的请求拦截器,主要作用是给所有请求的请求头里加上token
server.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("token")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
//这里是正常获取数据用的响应拦截器,正常数据请求都是200状态码,当拦截到201状态码时,代表token过期了,
server.interceptors.response.use(async(res)=>{
if(res.status==201){
//获取新token
let bl=await getNewToken()
if(bl){
//获取成功新token之后,把刚才token过期拦截到的请求重新发一遍,获取到数据之后把res覆盖掉
//这里是个关键点,下边这行代码里的第二个res是token过期后被拦截的那个请求,config里是该请求的详细信息,重新请求后返回的是第一个res,把失败的res覆盖掉,这里有点绕,文字不好表达,
res=await server.request(res.config)
}
}
return res
},error=>{
if(error.response.status==500||error.response.status==401||error.response.status==400){
router.push('/login')
ElMessage.error(error.response.data.message)
Promise.reject(error)
}

})
//这里是刷新token专用的axios对象,他的作用是给请求加上刷新token专用的refreshToken
serverRefreshToken.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("refreshToken")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
export default server

总结一下,前端部分:1,正常数据请求和刷新token用的请求分开了,各司其职。省去复杂的判断。2,获取新的token和refreshToken后更新原来旧的token和refreshToken。(完结)


作者:锋行天下
来源:juejin.cn/post/7337876697427148811
收起阅读 »

听说你会架构设计?来,弄一个公交&地铁乘车系统

1. 引言 1.1 上班通勤的日常 “叮铃铃”,“叮铃铃”,早上七八点,你还在温暖的被窝里和闹钟“斗智斗勇”。 突然,你意识到已经快迟到了,于是像个闪电侠一样冲进卫生间,速洗漱,急穿衣,左手抄起手机,右手拿起面包,边穿衣边啃早餐。 这个时候,通勤的老难题又摆...
继续阅读 »

1. 引言


1.1 上班通勤的日常


“叮铃铃”,“叮铃铃”,早上七八点,你还在温暖的被窝里和闹钟“斗智斗勇”。



突然,你意识到已经快迟到了,于是像个闪电侠一样冲进卫生间,速洗漱,急穿衣,左手抄起手机,右手拿起面包,边穿衣边啃早餐。


这个时候,通勤的老难题又摆在了你面前:要不要吃完这口面包、刷牙和洗脸,还是先冲出门赶车?


好不容易做出了一个艰难的决定——放下面包,快步冲出门。你拿出手机,点开了熟悉的地铁乘车 App 或公交地铁乘车码小程序。


然后,一张二维码在屏幕上亮了起来,这可是你每天通勤的“敲门砖”。





你快步走到地铁站,将手机二维码扫描在闸机上,"嗖"的一声,闸机打开,你轻松通过,不再需要排队买票,不再被早高峰的拥挤闹心。


你走进地铁车厢,挤到了一个角落,拿出手机,开始计划一天的工作。


1.2 公交&地铁乘车系统


正如上文所说,人们只需要一台手机,一个二维码就可以完成上班通勤的所有事项。


那这个便捷的公交或地铁乘车系统是如何设计的呢?它背后的技术和架构是怎样支撑着你我每天的通勤生活呢?


今天让我们一起揭开这个现代都市打工人通勤小能手的面纱,深入探讨乘车系统的设计与实现


在这个文章中,小❤将带你走进乘车系统的世界,一探究竟,看看它是如何在短短几年内从科幻电影中走出来,成为我们日常生活不可或缺的一部分。


2. 需求设计


2.1 功能需求




  • 用户注册和登录: 用户可以通过手机应用或小程序注册账号,并使用账号登录系统。

  • 路线查询: 用户可以查询地铁的线路和站点信息,包括发车时间、车票价格等。

  • 获取乘车二维码: 系统根据用户的信息生成乘车二维码。

  • 获取地铁实时位置: 用户可以查询地铁的实时位置,并查看地铁离当前站台还有多久到达。

  • 乘车扫描和自动支付: 用户在入站和出站时通过扫描二维码来完成乘车,系统根据乘车里程自动计算费用并进行支付。

  • 交易记录查询: 用户可以查询自己的交易历史记录,包括乘车时间、金额、线路等信息。


2.2 乘车系统的非功能需求


乘车系统的用户量非常大,据《中国主要城市通勤检测报告-2023》数据显示,一线城市每天乘公交&地铁上班的的人数普遍超过千万,平均通勤时间在 45-60 分钟,并集中在早高峰和晚高峰时段。


所以,设计一个热点数据分布非均匀、人群分布非均匀的乘车系统时,需要考虑如下几点:



  • 用户分布不均匀,一线城市的乘车系统用户,超出普通城市几个数量级。

  • 时间分布不均匀,乘车系统的设计初衷是方便上下班通勤,所以早晚高峰的用户数会高出其它时间段几个数量级。

  • 高并发: 考虑到公交车/地铁系统可能同时有大量的用户在高峰时段使用,系统需要具备高并发处理能力。

  • 高性能: 为了提供快速的查询和支付服务,系统需要具备高性能,响应时间应尽可能短。

  • 可扩展性: 随着用户数量的增加,系统应该容易扩展,以满足未来的需求。

  • 可用性: 系统需要保证24/7的可用性,随时提供服务。

  • 安全和隐私保护: 系统需要确保用户数据的安全和隐私,包括支付信息和个人信息的保护。


3. 概要设计


3.1 核心组件




  • 前端应用: 开发手机 App 和小程序,提供用户注册、登录、查询等功能。

  • 后端服务: 设计后端服务,包括用户管理、路线查询、二维码管理、订单处理、支付系统等。

  • 数据库: 使用关系型数据库 MySQL 集群存储用户信息、路线信息、交易记录等数据。

  • 推送系统: 将乘车后的支付结果,通过在线和离线两种方式推送给用户手机上。

  • 负载均衡和消息队列: 考虑使用负载均衡和消息队列技术来提高系统性能。


3.2 乘车流程


1)用户手机与后台系统的交互


交互时序图如下:



1. 用户注册和登录: 用户首先需要在手机应用上注册并登录系统,提供个人信息,包括用户名、手机号码、支付方式等。


2. 查询乘车信息: 用户可以使用手机应用查询公交车/地铁的路线和票价信息,用户可以根据自己的出行需求选择合适的线路。


3. 生成乘车二维码: 用户登录后,系统会生成一个用于乘车的二维码,这个二维码可以在用户手机上随时查看。这个二维码是城市公交系统的通用乘车二维码,同时该码关联到用户的账户和付款方式,用户可以随时使用它乘坐任何一辆公交车或地铁。


2)用户手机与公交车的交互


交互 UML 状态图如下:




  1. 用户进站扫码: 当用户进入地铁站时,他们将手机上的乘车码扫描在进站设备上。这个设备将扫描到的乘车码发送给后台系统。

  2. 进站数据处理: 后台系统接收到进站信息后,会验证乘车码的有效性,检查用户是否有进站记录,并记录下进站的时间和地点。

  3. 用户出站扫码: 用户在乘车结束后,将手机上的乘车码扫描在出站设备上。

  4. 出站数据处理: 后台系统接收到出站信息后,会验证乘车码的有效性,检查用户是否有对应的进站记录,并记录下出站的时间和地点。


3)后台系统的处理



  1. 乘车费用计算: 基于用户的进站和出站地点以及乘车规则,后台系统计算乘车费用。这个费用可以根据不同的城市和运营商有所不同。

  2. 费用记录和扣款: 系统记录下乘车费用,并从用户的付款方式(例如,支付宝或微信钱包)中扣除费用。

  3. 乘车记录存储: 所有的乘车记录,包括进站、出站、费用等信息,被存储在乘车记录表中,以便用户查看和服务提供商进行结算。

  4. 通知用户: 如果有需要,系统可以向用户发送通知,告知他们的乘车费用已被扣除。

  5. 数据库交互: 在整个过程中,系统需要与数据库交互来存储和检索用户信息、乘车记录、费用信息等数据。


3. 详细设计


3.1 数据库设计



  • 用户信息表(User) ,包括用户ID、手机号、密码、支付方式、创建时间等。

  • 二维码表 (QRCode) ,包括二维码ID、用户ID、城市ID、生成时间、有效期及二维码数据等。

  • 车辆&地铁车次表 (Vehicle) ,包括车辆ID、车牌或地铁列车号、车型(公交、地铁)、扫描设备序列号等。

  • 乘车记录表 (TripRecord) ,包括记录ID、用户ID、车辆ID、上下车时间、起止站点等。

  • 支付记录表 (PaymentRecord) ,包括支付ID、乘车记录ID、交易时间、交易金额、支付方式、支付状态等。


以上是一些在公交车&地铁乘车系统中需要设计的数据库表及其字段的基本信息,后续可根据具体需求和系统规模,还可以进一步优化表结构和字段设计,以满足性能和扩展性要求。


详细设计除了要设计出表结构以外,我们还针对两个核心问题进行讨论:



  • 最短路线查询



  • 乘车二维码管理


3.2 最短路线查询


根据交通部门给的公交&地铁路线,我们可以绘制如下站点图:



假设图中的站点有 A-F,涉及到的交通工具有地铁 1 号线和 2 路公交,用户的起点和终点分别为 A、F 点。我们可以使用 Dijkstra 算法来求两点之间的最短路径,具体步骤为:


步骤已遍历集合未遍历集合
1选入A,此时最短路径 A->A = 0,再以 A 为中间点,开始寻找下一个邻近节点{B、C、D、E、F},其中与 A 相邻的节点有 B 和 C,AB=6,AC=3。接下来,选取较短的路径节点 C 开始遍历
2选取C,A->C=3,此时已遍历集合为{A、C},以 A 和 C 为中间点,开始寻找下一个邻近节点{B、D、E、F},其中与 A、C 相邻的节点有 B 和 D,AB=6,ACD=3+4=7。接下来,选取较短的路径节点 B 开始遍历
3选取B,A->B=6,此时已遍历集合为{A、C、B},A 相邻的节点已经遍历结束,开始寻找和 B、C 相近的节点{D、E、F},其中与 B、C 相邻的节点有 D,节点 D 在之前已经有了一个距离记录(7),现在新的可选路径是 ABD=6+5=11。显然第一个路径更短,于是将 D 的最近距离 7 加入到集合中
4选取D,A->D=7,此时已遍历集合为{A、C、B、D},寻找 D 相邻的节点{E、F},其中 DE=2,DF=3,选取最近路径的节点 E 加入集合
5选取 E,A->E=7+2=9,此时已遍历集合为{A、C、B、D、E},继续寻找 D 和 E 相近的节点{F},其中 DF=3,DEF=2+5=7,于是F的最近距离为7+3=10.
6选取F,A->F=10,此时遍历集合为{A、C、B、D、E、F}所有节点已遍历结束,从 A 点出发,它们的最近距离分别为{A=0,C=3,B=6,D=7,E=9,F=10}

在用户查询路线之前,交通部门会把公交 & 地铁的站点经纬度信息输入到路线管理系统,并根据二维的空间经纬度编码存储对应的站点信息。


我们设定西经为负,南纬为负,所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。如果以本初子午线、赤道为界,地球可以分成 4 个部分。



根据这个原理,我们可以先将二维的空间经纬度编码成一个字符串,来唯一标识用户或站点的位置信息。再通过 Redis 的 GeoHash 算法,来获取用户出发点附近的所有站点信息。


GeoHash 算法的原理是将一个位置的经纬度换算成地址编码字符串,表示在某个矩形区域,通过这个算法可以快速找到同一个区域的所有站点


一旦获得了起始地点的经纬度,系统就可以根据附近的站点信息,调用路线管理系统来查找最佳的公交或地铁路线。


一旦用户选择了一条路线,导航引擎启动并提供实时导航指引。导航引擎可能会使用地图数据和 GPS 定位来指导用户前往起止站点。


3.3 乘车二维码管理


乘车码是通过 QR 码(Quick Response Code)技术生成的,它比传统的 Bar Code 条形码能存更多的信息,也能表示更多的数据类型,如图所示:



二维码的生成非常简单,拿 Go 语言来举例,只需引入一个三方库:


import "github.com/skip2/go-qrcode"

func main() {
    qr,err:=qrcode.New("https://mp.weixin.qq.com",qrcode.Medium)
if err != nil {
    log.Fatal(err)
else {
    qr.BackgroundColor = color.RGBA{50,205,50,255//定义背景色
    qr.ForegroundColor = color.White //定义前景色
    qr.WriteFile(256,"./wechatgzh_qrcode.png"//转成图片保存
    }
}

以下是该功能用户和系统之间的交互、二维码信息存储、以及高并发请求处理的详细说明:



  1. 用户与系统交互: 用户首先在手机 App 上登录,系统会验证用户的身份和付款方式。一旦验证成功,系统根据用户的身份信息和付款方式,动态生成一个 QR 码,这个 QR 码包含了用户的标识信息和相关的乘车参数。

  2. 二维码信息存储: 生成的二维码信息需要在后台进行存储和关联。通常,这些信息会存储在一个专门的数据库表中,该表包含以下字段:



    • 二维码ID:主键ID,唯一标识一个二维码。

    • 用户ID:与乘车码关联的用户唯一标识。

    • 二维码数据:QR码的内容,包括用户信息和乘车参数。

    • 生成时间:二维码生成的时间戳,用于后续的验证和管理。

    • 有效期限:二维码的有效期,通常会设置一个时间限制,以保证安全性。



  3. 高并发请求处理: 在高并发情况下,大量的用户会同时生成和扫描二维码,因此需要一些策略来处理这些请求:



    • 负载均衡: 后台系统可以采用负载均衡技术,将请求分散到多个服务器上,以分担服务器的负载。

    • 缓存优化: 二维码的生成是相对耗时的操作,可以采用 Redis 来缓存已生成的二维码,避免重复生成。

    • 限制频率: 为了防止滥用,可以限制每个用户生成二维码的频率,例如,每分钟只允许生成 5  次,这可以通过限流的方式来实现。




总之,通过 QR 码技术生成乘车码,后台系统需要具备高并发处理的能力,包括负载均衡、缓存和频率限制等策略,以确保用户能够快速获得有效的乘车二维码。


同时,二维码信息需要被安全地存储和管理,比如:加密存储以保护用户的隐私和付款信息。



不清楚如何限流的,可以看我之前的这篇文章:若我问到高可用,阁下又该如何应对呢?



4. 乘车系统的发展


4.1 其它设计


除此之外,公交车或地铁的定位和到站时间计算可能还涉及定位设备、GPS 系统、NoSQL 数据库、用户 TCP 连接管理系统等核心组件,并通过实时数据采集、位置处理、到站时间计算和信息推送等流程来为用户提供准确的乘车信息。


同时,自动支付也是为了方便用户的重要功能,可以通过与第三方支付平台的集成来实现。


4.2 未来发展


公交车/地铁乘车系统的未来发展可以包括以下方向:



  • 智能化乘车: 引入智能设备,如人脸自动识别乘客、人脸扣款等。

  • 大数据分析: 利用大数据技术分析乘车数据,提供更好的服务。


在设计和发展过程中,也要不断考虑用户体验、性能和安全,确保系统能够满足不断增长的需求。


由于篇幅有限,文章就到此结束了。


希望读者们能对公交&地铁乘车系统的设计有更深入的了解,并和小❤一起期待未来更多的交通创新解决方案叭~


作者:xin猿意码
来源:juejin.cn/post/7287495466514055202
收起阅读 »

前端要懂的Docker部分

前言 最近学习部署的时候,发现前端部署可以通过多种方式来进行部署,web服务器,Docker,静态部署:Github page,网站托管平台:Vercel,还有一些自动化部署的东西目前还没有学到... Docker开始 简介 Docker是一个应用打包,分发,...
继续阅读 »

前言


最近学习部署的时候,发现前端部署可以通过多种方式来进行部署,web服务器,Docker,静态部署:Github page,网站托管平台:Vercel,还有一些自动化部署的东西目前还没有学到...


Docker开始


简介


Docker是一个应用打包,分发,部署的工具,它就相当于一个容器,将你想要的一些依赖、第三方库、环境啥的和代码进行一块打包为镜像,并上传到镜像仓库里面,这样别人可以直接从镜像仓库里面直接拉去这个代码来进行运行,可以适应不同的电脑环境,打造了一个完全封闭的环境。



  • 打包:将软件运行所需要的依赖、第三方库、软件打包到一起,变成一个安装包

  • 分发:将你打包好的安装包上传到一个镜像 仓库,其他人可以很方便的获取安装

  • 部署:拿着安装包就可以一个命令运行起来你的应用,自动模拟出一摸一样的运行环境


为什么使用Docker



Docker 的出现主要是为了解决以下问题:“在我的机器上运行正常,但为什么到你的机器上就运行不正常了?”。



平常开发的时候,项目在本地跑的好好的,但是其他人想要在他的电脑上去跑你的应用程序,但是却跑不起来,他得配置数据库,Web 服务器,插件啥的,非常不方便。Docker的出现解决了这些问题。


基本术语


镜像:理解为软件安装包,可以方便的进行传播和安装,它包含了运行应用程序所需的所有元素,包括代码、运行时环境、库、环境变量和配置文件。



  • 镜像可以通过Dockerfile来进行创建

  • Dockerfile就相当于一个脚本,编写一些命令,他会识别这些命令并且执行


容器:软件安装后的状态,每个软件运行的环境都是独立的、隔离的,称之为容器。


仓库:仓库是存放 Docker 镜像的地方。仓库允许你分享你的镜像,你可以将你的镜像推送(push)到仓库,也可以从仓库拉取(pull)其他人分享的镜像。



  • Docker 提供了一个公共的仓库 Docker Hub,你可以在上面上传你的镜像,或者寻找你需要的镜像。


常见命令



  • docker run: 用于从 Docker 镜像启动一个容器。例如,docker run -p 8080:80 -d my-app 将从名为 "my-app" 的 Docker 镜像启动一个新的容器,并将容器的 8080 端口映射到主机的 80 端口。

  • docker build: 用于从 Dockerfile 构建 Docker 镜像。例如,docker build -t my-app . 将使用当前目录中的 Dockerfile 构建一个名为 "my-app" 的 Docker 镜像。

  • docker pull: 用于从 Docker Hub 或其他 Docker 注册服务器下载 Docker 镜像。例如,docker pull nginx 将从 Docker Hub 下载官方的 Nginx 镜像。

  • docker push: 用于将 Docker 镜像推送到 Docker Hub 或其他 Docker 注册服务器。例如,docker push my-app 将名为 "my-app" 的 Docker 镜像推送到你的 Docker Hub 账户。

  • docker ps: 用于列出正在运行的 Docker 容器。添加 -a 选项(docker ps -a)可以列出所有容器,包括已停止的。

  • docker stop: 用于停止正在运行的 Docker 容器。例如,docker stop my-container 将停止名为 "my-container" 的 Docker 容器。

  • docker rm: 用于删除 Docker 容器。例如,docker rm my-container 将删除名为 "my-container" 的 Docker 容器。

  • docker rmi: 用于删除 Docker 镜像。例如,docker rmi my-app 将删除名为 "my-app" 的 Docker 镜像。

  • docker logs: 用于查看 Docker 容器的日志。例如,docker logs my-container 将显示名为 "my-container" 的 Docker 容器的日志。

  • docker exec: 用于在正在运行的 Docker 容器中执行命令。例如,docker exec -it my-container bash 将在名为 "my-container" 的 Docker 容器中启动一个 bash shell。
    docker常见命令



ps:不会使用就:docker load --help



编写Dockerfile


FROM nginx:latest
# 定义作者
MAINTAINER Merikle

#删除目录下的default.conf文件
#RUN rm /etc/nginx/conf.d/default.conf
#设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

#将本地nginx.conf配置覆盖nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 将dist文件中的内容复制到 /usr/share/nginx/html/ 这个目录下面
COPY dist/ /usr/share/nginx/html/
#声名端口
EXPOSE 8748

RUN echo 'web project build success!!'

FROM:指定基础镜像


RUN:一般指安装的过程


COPY:拷贝本地文件到镜像的指定目录


ENV:环境变量


EXPOSE:指定容器运行时监听到的端口,是给镜像的使用者看的


ENTRYPOINT:镜像中应用的启动命令,容器运行时调用


编写完Dockerfile生成镜像:


docker build -t test:v1 .


运行镜像:


docker run -p 8080:8080 --name test-hello test:v1


意思:跑在8080端口将test:v1命名为text-hello


之后你可以发布到上面说的仓库里面




  • 命令行登录账号: docker login -u username

  • 新建一个tag,名字必须跟你注册账号一样 docker tag test:v1 username/test:v1

  • 推上去 docker push username/test:v1

  • 部署试下 docker run -dp 8080:8080 username/test:v1


实践一下Docker部署前端项目


核心思想:


将前端打包的dist放到nginx里面,然后添加一个nginx.conf文件。


docker的话就是多了一个Dockerfile文件来构建镜像而已。


nginx.conf文件的编写


#nginx.conf文件编写
#user nobody;
worker_processes 1;

#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;

#pid logs/nginx.pid;


events {
worker_connections 1024;
}


http {
include mime.types;
default_type application/octet-stream;

#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';

#access_log logs/access.log main;

sendfile on;
#tcp_nopush on;

#keepalive_timeout 0;
keepalive_timeout 65;

#gzip on;

server {
#监听的端口
listen 8748;
#请填写绑定证书的域名或者IP
server_name 121.199.29.3;

gzip on;
gzip_min_length 1k;
gzip_comp_level 9;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";

#charset koi8-r;

#access_log logs/host.access.log main;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}

#error_page 404 /404.html;

# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}

# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}

# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}

# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}


# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;

# location / {
# root html;
# index index.html index.htm;
# }
#}


# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;

# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;

# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;

# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;

# location / {
# root html;
# index index.html index.htm;
# }
#}

}


Dockerfile文件的编写


# Dockerfile文件

FROM nginx:latest
# 定义作者
MAINTAINER Merikle

#删除目录下的default.conf文件
#RUN rm /etc/nginx/conf.d/default.conf
#设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

#将本地nginx.conf配置覆盖nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 将dist文件中的内容复制到 /usr/share/nginx/html/ 这个目录下面
COPY dist/ /usr/share/nginx/html/
#声名端口
EXPOSE 8748

RUN echo 'web project build success!!'


然后将这些压缩成zip(或者本地打包为镜像,然后上传到镜像仓库里面,在服务器里面拉取镜像就可以了),然后传到服务器中,进行解压,构建镜像并且运行镜像来进行前端部署。


部署node项目


Dockerfile的编写


FROM node:11
MAINTAINER Merikle

#
复制代码
ADD . /mongo-server

#
设置容器启动后的默认运行目录
WORKDIR /mongo-server

#
运行命令,安装依赖
# RUN 命令可以有多个,但是可以用 && 连接多个命令来减少层级。
# 例如 RUN npm install && cd /app && mkdir logs
RUN npm install --registry=https://registry.npm.taobao.org

#
CMD 指令只能一个,是容器启动后执行的命令,算是程序的入口。
# 如果还需要运行其他命令可以用 && 连接,也可以写成一个shell脚本去执行。
# 例如 CMD cd /app && ./start.sh
CMD node app.js

然后构建镜像


发布镜像到hub上面


在服务器上面进行拉取镜像并且运行镜像。


部署mongodb


这个还没有部署,之后再说啦。


作者:Charlotten
来源:juejin.cn/post/7332290436345905187
收起阅读 »

SQL中为什么不要使用1=1

最近看几个老项目的SQL条件中使用了1=1,想想自己也曾经这样写过,略有感触,特别拿出来说道说道。 编写SQL语句就像炒菜,每一种调料的使用都会影响菜品的最终味道,每一个SQL条件的加入也会影响查询的执行效率。那么 1=1 存在什么样的问题呢?为什么又会使用呢...
继续阅读 »

最近看几个老项目的SQL条件中使用了1=1,想想自己也曾经这样写过,略有感触,特别拿出来说道说道。


编写SQL语句就像炒菜,每一种调料的使用都会影响菜品的最终味道,每一个SQL条件的加入也会影响查询的执行效率。那么 1=1 存在什么样的问题呢?为什么又会使用呢?


为什么会使用 1=1?


在动态构建SQL查询时,开发者可能会不确定最终需要哪些条件。这时候,他们就会使用“1=1”作为一个始终为真的条件,让接下来的所有条件都可以方便地用“AND”连接起来,就像是搭积木的时候先放一个基座,其他的积木块都可以在这个基座上叠加。


就像下边这样:


SELECT * FROM table WHERE 1=1
<if test="username != null">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>

这样就不用在增加每个条件之前先判断是否需要添加“AND”。


1=1 带来的问题


性能问题


我们先来了解一下数据库查询优化器的工作原理。查询优化器就像是一个聪明的图书管理员,它知道如何最快地找到你需要的书籍。当你告诉它所需书籍的特征时,它会根据这些信息选择最快的检索路径。比如你要查询作者是“谭浩强”的书籍,它就选择先通过作者索引找到书籍索引,再通过书籍索引找到对应的书籍,而不是费力的把所有的书籍遍历一遍。


但是,如果我们告诉它一些无关紧要的信息,比如“我要一本书,它是一本书”,这并不会帮助管理员更快地找到书,反而可能会让他觉得困惑。一个带有“1=1”的查询可能会让数据库去检查每一条记录是否满足这个始终为真的条件,这就像是图书管理员不得不检查每一本书来确认它们都是书一样,显然是一种浪费。


不过这实际上可能也不会产生问题,因为现代数据库的查询优化器已经非常智能,它们通常能够识别出像 1=1 这样的恒真条件,并在执行查询计划时优化掉它们。在许多情况下,即使查询中包含了1=1,数据库的性能也不会受到太大影响,优化器会在实际执行查询时将其忽略。


代码质量


不过,我们仍然需要避免在查询中包含 1=1,有以下几点考虑:



  1. 代码清晰性:即使数据库可以优化掉这样的条件,但对于阅读SQL代码的人来说,1=1可能会造成困惑。代码的可读性和清晰性非常重要,特别是在团队协作的环境中。

  2. 习惯养成:即使在当前的数据库系统中1=1不会带来性能问题,习惯了写不必要的代码可能会在其他情况下引入实际的性能问题。比如,更复杂的无用条件可能不会那么容易被优化掉。

  3. 优化器的限制:虽然现代优化器很强大,但它们并不是万能的。在某些复杂的查询场景中,即使是简单的 1=1 也可能对优化器的决策造成不必要的影响,比如索引的使用。

  4. 跨数据库兼容性:不同的数据库管理系统(DBMS)可能有不同的优化器能力。一个系统可能轻松优化掉1=1,而另一个系统则可能不那么高效。编写不依赖于特定优化器行为的SQL语句是一个好习惯。


编写尽可能高效、清晰和准确的SQL语句,不仅有助于保持代码的质量,也让代码具有更好的可维护性和可扩展性。


替代 1=1 的更佳做法


现在开发者普遍使用ORM框架来操作数据库了,还在完全手写拼SQL的同学可能需要反思下了,这里给两个不同ORM框架下替代1=1的方法。


假设我们有一个用户信息表 user,并希望根据传入的参数动态地过滤用户。


首先是Mybatis


<!-- MyBatis映射文件片段 -->
<select id="selectUsersByConditions" parameterType="map" resultType="com.example.User">
SELECT * FROM user
<where>
<!-- 使用if标签动态添加条件 -->
<if test="username != null and username != ''">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>
<!-- 更多条件... -->
</where>
</select>

在 MyBatis 中,避免使用 WHERE 1=1 的典型方法是利用动态SQL标签(如 )来构建条件查询。 标签会自动处理首条条件前的 AND 或 OR。当没有满足条件的 或其他条件标签时, 标签内部的所有内容将被忽略,从而不会生成多余的 AND 或 WHERE 子句。


再看看 Entity Framework 的方法:


var query = context.User.AsQueryable();
if (!string.IsNullOrEmpty(username))
{
query = query.Where(b => b.UserName.Contains(username));
}
if (age>0)
{
query = query.Where(b => b.Age = age);
}
var users = query.ToList();

这是一种函数式编程的写法,最终生成SQL时,框架会决定是否在条件前增加AND,而不需要人为的增加 1=1。


总结


“1=1”在SQL语句中可能看起来无害,但实际上它是一种不良的编程习惯,可能会导致性能下降。就像在做饭时不会无缘无故地多加调料一样,我们在编写SQL语句时也应该避免添加无意义的条件。


每一行代码都应该有它存在的理由,不要让你的数据库像一个困惑的图书管理员,浪费时间在不必要的事情上。


作者:萤火架构
来源:juejin.cn/post/7337513754970095667
收起阅读 »

对代码封装的一点思考

之前看过一篇文章讲代码封装的过程,下面从简述文章的内容开始梳理下自己对代码封装的一点思考. --- 以下是之前文章内容简述 --- 在业务迭代的初期,代码中有一段业务逻辑A,由于A逻辑在项目中使用比较广泛,于是对A逻辑进行了代码逻辑的封装,包括参数的预设等 ...
继续阅读 »

之前看过一篇文章讲代码封装的过程,下面从简述文章的内容开始梳理下自己对代码封装的一点思考.


--- 以下是之前文章内容简述 ---


在业务迭代的初期,代码中有一段业务逻辑A,由于A逻辑在项目中使用比较广泛,于是对A逻辑进行了代码逻辑的封装,包括参数的预设等

初始化封装

随着业务的发展,又出现了B模块,B模块中几乎可以直接使用A逻辑,但是需要对A逻辑进行一些小小的改动,比如入参的调整、逻辑分支的增加

再次封装

这种通过修改较小范围代码就能实现功能的方式在实际开发中是很常规的操作,它并没有不好,只是当这种修改变的次数多的时候,最开始封装的逻辑A就变成了‘四不像’,它似乎可以服务于A模块,但是确有些冗余,出现了‘我好像看不懂这段代码’,‘这段代码还是别改了’,‘你改吧 我是改不了’的场景。在多次封装之后,逻辑A容易出现以下的问题



  • 入参不可控

  • 逻辑分支复杂,不清晰,修改范围不确定,容易对其他模块产生影响

  • 复用度逐渐下降,解决方法



    1. 将就不再继续新增逻辑,变成'老大难'问题,总有爆发的一天

    2. 重写相关逻辑,功能回测范围大,影响范围不可控,容易出现漏测等




--- 以下是正文 ---


提前封装或者过度封装对实际业务的编写都有很大影响,最实际的感受就是这段代码不好写,这个逻辑看不懂。封装的代码逻辑是结果,封装的出发点是因,从出发点去看而不是面向结果的封装似乎更能编写出可维护性的代码。以下是自己对封装的一点思考。


降低复杂度


复杂度

通过对业务中分散的相似逻辑提取,就能一定程度上降低项目的复杂度和重复度的问题。在修改相应的业务逻辑时候就相对集中和可控。新增逻辑的修改也能做到应用范围的同步。


解耦逻辑


在设计模式中需要设计的模块尽量遵循单一职责,但是在实际编写代码的过程中很容易出现模块功能的耦合,比如常见的:



  • 视图层逻辑与数据层逻辑耦合

  • 非相关逻辑的入侵 比如视图的逻辑需要感知请求状态的改变做相应的逻辑处理


通过将相关的逻辑都聚合到模块封装的内部,就能较低其他模块对不相关逻辑的感知,也就做到了解耦。在下面的例子中,通过将A逻辑的关联逻辑都封装进A模块中,降低了功能模块对封装逻辑的感知。内部封装

相关例子:



  • ahooks中的useRequest中,就通过对请求状态和数据的操作就行封装,将数据相关操作做到了内聚,减轻了视图层的负担

  • Antd pro components的table


分层


分层封装

在例子中,通过将逻辑A中的相关逻辑提取到上层,分层的方式实现了模块功能的内聚与解耦。在前端的MVC中就通过将相关操作封装成Controller,来完成逻辑的解耦


复用(一致性,可控性)


从降低复杂度、逻辑结构、分层设计最终的目标是达到封装代码的复用。通过复用能控制代码的一致性,实现修改代码行为的可控。



-- 写在最后 --


代码的优化或者设计是应该在理解业务的基础上,从相对大的视角出发来看的。在实际做的时候容易出现缺少全局视角的设计缺失和不完善,也容易出现过度设计,需要仔细去把握这个度量。


作者:前端小板凳
来源:juejin.cn/post/7337354931479085096
收起阅读 »

分类树,我从2s优化到0.1s

前言 分类树查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。 但就是这样一个简单的分类树查询功能,我们却优化了5次。 到底是怎么回事呢? 背景 我们的网站使用了SpringBoot推荐的模板引擎:Thymeleaf,进行动态渲染。 它是一个XM...
继续阅读 »

前言


分类树查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。



但就是这样一个简单的分类树查询功能,我们却优化了5次。


到底是怎么回事呢?


背景


我们的网站使用了SpringBoot推荐的模板引擎:Thymeleaf,进行动态渲染。


它是一个XML/XHTML/HTML5模板引擎,可用于Web与非Web环境中的应用开发。


它提供了一个用于整合SpringMVC的可选模块,在应用开发中,我们可以使用Thymeleaf来完全代替JSP或其他模板引擎,如Velocity\FreeMarker等。


前端开发写好Thymeleaf的模板文件,调用后端接口获取数据,进行动态绑定,就能把想要的内容展示给用户。


由于当时这个是从0-1的新项目,为了开快速开发功能,我们第一版接口,直接从数据库中查询分类数据,组装成分类树,然后返回给前端。


通过这种方式,简化了数据流程,快速把整个页面功能调通了。


第1次优化


我们将该接口部署到dev环境,刚开始没啥问题。


随着开发人员添加的分类越来越多,很快就暴露出性能瓶颈。


我们不得不做优化了。


我们第一个想到的是:加Redis缓存


流程图如下:

于是暂时这样优化了一下:



  1. 用户访问接口获取分类树时,先从Redis中查询数据。

  2. 如果Redis中有数据,则直接数据。

  3. 如果Redis中没有数据,则再从数据库中查询数据,拼接成分类树返回。

  4. 将从数据库中查到的分类树的数据,保存到Redis中,设置过期时间5分钟。

  5. 将分类树返回给用户。


我们在Redis中定义一个了key,value是一个分类树的json格式转换成了字符串,使用简单的key/value形式保存数据。


经过这样优化之后,dev环境的联调和自测顺利完成了。


第2次优化


我们将这个功能部署到st环境了。


刚开始测试同学没有发现什么问题,但随着后面不断地深入测试,隔一段时间就出现一次首页访问很慢的情况。


于是,我们马上进行了第2次优化。


我们决定使用Job定期异步更新分类树到Redis中,在系统上线之前,会先生成一份数据。


当然为了保险起见,防止Redis在哪条突然挂了,之前分类树同步写入Redis的逻辑还是保留。


于是,流程图改成了这样:

增加了一个job每隔5分钟执行一次,从数据库中查询分类数据,封装成分类树,更新到Redis缓存中。


其他的流程保持不变。


此外,Redis的过期时间之前设置的5分钟,现在要改成永久。


通过这次优化之后,st环境就没有再出现过分类树查询的性能问题了。


第3次优化


测试了一段时间之后,整个网站的功能快要上线了。


为了保险起见,我们需要对网站首页做一次压力测试。


果然测出问题了,网站首页最大的qps是100多,最后发现是每次都从Redis获取分类树导致的网站首页的性能瓶颈。


我们需要做第3次优化。


该怎么优化呢?


答:加内存缓存。


如果加了内存缓存,就需要考虑数据一致性问题。


内存缓存是保存在服务器节点上的,不同的服务器节点更新的频率可能有点差异,这样可能会导致数据的不一致性。


但分类本身是更新频率比较低的数据,对于用户来说不太敏感,即使在短时间内,用户看到的分类树有些差异,也不会对用户造成太大的影响。


因此,分类树这种业务场景,是可以使用内存缓存的。


于是,我们使用了Spring推荐的caffine作为内存缓存。


改造后的流程图如下:



  1. 用户访问接口时改成先从本地缓存分类数查询数据。

  2. 如果本地缓存有,则直接返回。

  3. 如果本地缓存没有,则从Redis中查询数据。

  4. 如果Redis中有数据,则将数据更新到本地缓存中,然后返回数据。

  5. 如果Redis中也没有数据(说明Redis挂了),则从数据库中查询数据,更新到Redis中(万一Redis恢复了呢),然后更新到本地缓存中,返回返回数据。



需要注意的是,需要改本地缓存设置一个过期时间,这里设置的5分钟,不然的话,没办法获取新的数据。



这样优化之后,再次做网站首页的压力测试,qps提升到了500多,满足上线要求。
最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。


我以往的技术群里技术氛围非常不错,大佬很多。


image.png


加微信:su_san_java,备注:加群,即可加入该群。


第4次优化


之后,这个功能顺利上线了。


使用了很长一段时间没有出现问题。


两年后的某一天,有用户反馈说,网站首页有点慢。


我们排查了一下原因发现,分类树的数据太多了,一次性返回了上万个分类。


原来在系统上线的这两年多的时间内,运营同学在系统后台增加了很多分类。


我们需要做第4次优化。


这时要如何优化呢?


限制分类树的数量?


答:也不太现实,目前这个业务场景就是有这么多分类,不能让用户选择不到他想要的分类吧?


这时我们想到最快的办法是开启nginxGZip功能。


让数据在传输之前,先压缩一下,然后进行传输,在用户浏览器中,自动解压,将真实的分类树数据展示给用户。


之前调用接口返回的分类树有1MB的大小,优化之后,接口返回的分类树的大小是100Kb,一下子缩小了10倍。


这样简单的优化之后,性能提升了一些。


第5次优化


经过上面优化之后,用户很长一段时间都没有反馈性能问题。


但有一天公司同事在排查Redis中大key的时候,揪出了分类树。之前的分类树使用key/value的结构保存数据的。


我们不得不做第5次优化。


为了优化在Redis中存储数据的大小,我们首先需要对数据进行瘦身。


只保存需要用到的字段。


例如:


@AllArgsConstructor
@Data
public class Category {

private Long id;
private String name;
private Long parentId;
private Date inDate;
private Long inUserId;
private String inUserName;
private List children;
}

像这个分类对象中inDate、inUserId和inUserName字段是可以不用保存的。


修改自动名称。


例如:


@AllArgsConstructor
@Data
public class Category {
/**
* 分类编号
*/

@JsonProperty("i")
private Long id;

/**
* 分类层级
*/

@JsonProperty("l")
private Integer level;

/**
* 分类名称
*/

@JsonProperty("n")
private String name;

/**
* 父分类编号
*/

@JsonProperty("p")
private Long parentId;

/**
* 子分类列表
*/

@JsonProperty("c")
private List children;
}

由于在一万多条数据中,每条数据的字段名称是固定的,他们的重复率太高了。


由此,可以在json序列化时,改成一个简短的名称,以便于返回更少的数据大小。


这还不够,需要对存储的数据做压缩。


之前在Redis中保存的key/value,其中的value是json格式的字符串。


其实RedisTemplate支持,value保存byte数组


先将json字符串数据用GZip工具类压缩成byte数组,然后保存到Redis中。


再获取数据时,将byte数组转换成json字符串,然后再转换成分类树。


这样优化之后,保存到Redis中的分类树的数据大小,一下子减少了10倍,Redis的大key问题被解决了。




作者:苏三说技术
来源:juejin.cn/post/7233012756315963452
收起阅读 »

听说你会架构设计?来,弄一个红包系统

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。 1. 引言 当我那天拿着手机,正在和朋友们的微信群里畅聊着八卦新闻和即将到来的周末计划时,忽然一条带着喜意的消息扑面而来,消息正中间赫然写着八个大...
继续阅读 »

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。


1. 引言


当我那天拿着手机,正在和朋友们的微信群里畅聊着八卦新闻和即将到来的周末计划时,忽然一条带着喜意的消息扑面而来,消息正中间赫然写着八个大字:恭喜发财,大吉大利



抢红包!!相信大部分人对此都不陌生,自 2015 年春节以来,微信就新增了各类型抢红包功能,吸引了数以亿万级的用户参与体验,今天,我们就来聊一聊这个奇妙有趣的红包系统。


2. 概要设计


2.1 系统特点



抢红包系统从功能拆分,可以分为包红包、发红包、抢红包和拆红包 4 个功能。


对于系统特性来说,抢红包系统和秒杀系统类似。



每次发红包都是一次商品秒杀流程,包括商品准备,商品上架,查库存、减库存,以及秒杀开始,最终的用户转账就是红包到账的过程。


2.2 难点


相比秒杀活动,微信发红包系统的用户量更大,设计更加复杂,需要重视的点更多,主要包括以下几点。


1、高并发


海量并发请求,秒杀只有一次活动,但红包可能同一时刻有几十万个秒杀活动。


比如 2017 鸡年除夕,微信红包抢红包用户数高达 3.42 亿,收发峰值 76 万/秒,发红包 37.77 亿 个。


2、安全性要求


红包业务涉及资金交易,所以一定不能出现超卖、少卖的情况。



  • 超卖:发了 10 块钱,结果抢到了 11 块钱,多的钱只能系统补上,如此为爱发电应用估计早就下架了;

  • 少卖:发了 10 块钱,只抢了 9 块,多的钱得原封不动地退还用户,否则第二天就接到法院传单了。


3、严格事务


参与用户越多,并发 DB 请求越大,数据越容易出现事务问题,所以系统得做好事务一致性


这也是一般秒杀活动的难点所在,而且抢红包系统涉及金钱交易,所以事务级别要求更高,不能出现脏数据


3. 概要设计


3.1 功能说明


抢红包功能允许用户在群聊中发送任意个数和金额的红包,群成员可以抢到随机金额的红包,但要保证每个用户的红包金额不小于 0.01 元



抢红包的详细交互流程如下:



  1. 用户接收到抢红包通知,点击通知打开群聊页面;

  2. 用户点击抢红包,后台服务验证用户资格,确保用户尚未领取过此红包;

  3. 若用户资格验证通过,后台服务分配红包金额并存储领取记录;

  4. 用户在微信群中看到领取金额,红包状态更新为“已领取”;

  5. 异步调用支付接口,将红包金额更新到钱包里。


3.2 数据库设计


红包表 redpack 的字段如下:



  • id: 主键,红包ID

  • userId: 发红包的用户ID

  • totalAmount: 总金额

  • surplusAmount: 剩余金额

  • total: 红包总数

  • surplusTotal: 剩余红包总数


该表用来记录用户发了多少红包,以及需要维护的剩余金额。


红包记录表 redpack_record 如下:



  • id: 主键,记录ID

  • redpackId: 红包ID,外键

  • userId: 用户ID

  • amount: 抢到的金额


记录表用来存放用户具体抢到的红包信息,也是红包表的副表。


3.3 发红包



  1. 用户设置红包的总金额和个数后,在红包表中增加一条数据,开始发红包;

  2. 为了保证实时性和抢红包的效率,在 Redis 中增加一条记录,存储红包 ID 和总人数 n``;

  3. 抢红包消息推送给所有群成员。


3.4 抢红包


从 2015 年起,微信红包的抢红包和拆红包就分离了,用户点击抢红包后需要进行两次操作。


这也是为什么明明有时候抢到了红包,点开后却发现该红包已经被领取完了



抢红包的交互步骤如下:



  1. 抢红包:抢操作在 Redis 缓存层完成,通过原子递减的操作来更新红包个数,个数递减为 0 后就说明抢光了。

  2. 拆红包:拆红包时,首先会实时计算金额,一般是通过二倍均值法实现(即 0.01 到剩余平均值的 2 倍之间)。

  3. 红包记录:用户获取红包金额后,通过数据库的事务操作累加已经领取的个数和金额,并更新红包表和记录表。

  4. 转账:为了提升效率,最终的转账为异步操作,这也是为什么在春节期间,红包领取后不能立即在余额中看到的原因。


上述流程,在一般的秒杀活动中随处可见,但是,红包系统真的有这么简单吗?


当用户量过大时,高并发下的事务一致性怎么保证,数据分流如何处理,红包的数额分配又是怎么做的,接下来我们一一探讨。


4. 详细设计


由于是秒杀类设计,以及 money 分发,所以我们重点关注抢红包时的高并发解决方案和红包分配算法。


4.1 高并发解决方案


首先,抢红包系统的用户量很大,如果几千万甚至亿万用户同时在线发抢红包,请求直接打到数据库,必然会导致后端服务过载甚至崩溃。


而在这种业务量下,简单地对数据库进行扩容不仅会让成本消耗剧增,另一方面由于存在磁盘的性能瓶颈,所以大概率解决不了问题。


所以,我们将解决方案集中在 减轻系统压力、提升响应速度 上,接下来会从缓存、加锁、异步分治等方案来探讨可行性。


1、缓存


和大多数秒杀系统设计相似,由于抢红包时并发很高,如果直接操作 DB 里的数据表,可能触发 DB 锁的逻辑,导致响应不及时。



所以,我们可以在 DB 落盘之前加一层缓存,先限制住流量,再处理红包订单的数据更新。


这样做的优点是用缓存操作替代了磁盘操作,提升了并发性能,这在一般的小型秒杀活动中非常有效!


但是,随着微信使用发&抢红包的用户量增多,系统压力增大,各种连锁反应产生后,数据一致性的问题逐渐暴露出来:



  • 假设库存减少的内存操作成功,但是 DB 持久化失败了,会出现红包少发的问题;

  • 如果库存操作失败,DB 持久化成功,又可能会出现红包超发的问题。


而且在几十万的并发下,直接对业务加锁也是不现实的,即便是乐观锁。


2、加锁


在关系型 DB 里,有两种并发控制方法:分为乐观锁(又叫乐观并发控制,Optimistic Concurrency Control,缩写 “OCC”)和悲观锁(又叫悲观并发,Pessimistic Concurrency Control,缩写“PCC”)。



悲观锁在操作数据时比较悲观,认为别的事务可能会同时修改数据,所以每次操作数据时会先把数据锁住,直到操作完成


乐观锁正好相反,这种策略主打一个“信任”的思想,认为事务之间的数据竞争很小,所以在操作数据时不会加锁,直到所有操作都完成到提交时才去检查是否有事务更新(通常是通过版本号来判断),如果没有则提交,否则进行回滚。


在高并发场景下,由于数据操作的请求很多,所以乐观锁的吞吐量更大一些。但是从业务来看,可能会带来一些额外的问题:



  1. 抢红包时大量用户涌入,但只有一个可以成功,其它的都会失败并给用户报错,导致用户体验极差;

  2. 抢红包时,如果第一时间有很多用户涌入,都失败回滚了。过一段时间并发减小后,反而让手慢的用户抢到了红包

  3. 大量无效的更新请求和事务回滚,可能给 DB 造成额外的压力,拖慢处理性能。


总的来说,乐观锁适用于数据竞争小,冲突较少的业务场景,而悲观锁也不适用于高并发场景的数据更新。


因此对于抢红包系统来说,加锁是非常不适合的。


3、异步分治


综上所述,抢红包时不仅要解决高并发问题、还得保障并发的顺序性,所以我们考虑从队列的角度来设计。


我们知道,每次包红包、发红包、抢红包时,也有先后依赖关系,因此我们可以将红包 ID 作为一个唯一 Key,将发一次红包看作一个单独的 set,各个 set 相互独立处理



这样,我们就把海量的抢红包系统分成一个个的小型秒杀系统,在调度处理中,通过对红包 ID 哈希取模,将一个个请求打到多台服务器上解耦处理。


然后,为了保证每个用户抢红包的先后顺序,我们把一个红包相关的操作串行起来,放到一个队列里面,依次消费。


从上述 set 分流我们可以看出,一台服务器可能会同时处理多个红包的操作,所以,为了保证消费者处理 DB 不被高并发打崩,我们还需要在消费队列时用缓存来限制并发消费数量


抢红包业务消费时由于不存储数据,只是用缓存来控制并发。所以我们可以选用大数据量下性能更好的 Memcached。


除此之外,在数据存储上,我们可以用红包 ID 进行哈希分表,用时间维度对 DB 进行冷热分离,以此来提升单 set 的处理性能。


综上所述,抢红包系统在解决高并发问题上采用了 set 分治、串行化队列、双维度分库分表 等方案,使得单组 DB 的并发性能得到了有效提升,在应对数亿级用户请求时取得了良好的效果。


4.2 红包分配算法


抢红包后,我们需要进行拆红包,接下来我们讨论一下红包系统的红包分配算法。


红包金额分配时,由于是随机分配,所以有两种实现方案:实时拆分和预先生成。


1、实时拆分


实时拆分,指的是在抢红包时实时计算每个红包的金额,以实现红包的拆分过程。


这个对系统性能和拆分算法要求较高,例如拆分过程要一直保证后续待拆分红包的金额不能为空,不容易做到拆分的红包金额服从正态分布规律。


2、预先生成


预先生成,指的是在红包开抢之前已经完成了红包的金额拆分,抢红包时只是依次取出拆分好的红包金额。


这种方式对拆分算法要求较低,可以拆分出随机性很好的红包金额,但通常需要结合队列使用。


3、二倍均值法


综合上述优缺点考虑,以及微信群聊中的人数不多(目前最高 500 人),所以我们采用实时拆分的方式,用二倍均值法来生成随机红包,只满足随机即可,不需要正态分布。



故可能出现很大的红包差额,但这更刺激不是吗🐶



使用二倍均值法生成的随机数,每次随机金额会在 0.01 ~ 剩余平均值*2 之间。


假设当前红包剩余金额为 10 元,剩余个数为 5,10/5 = 2,则当前用户可以抢到的红包金额为:0.01 ~ 4 元之间。


4、算法优化


用二倍均值法生成的随机红包虽然接近平均值,但是在实际场景下:微信红包金额的随机性和领取的顺序有关系,尤其是金额不高的情况下


于是,小❤耗费 “巨资” 在微信群发了多个红包,得出了这样一个结论:如果发出的 红包总额 = 红包数*0.01 + 0.01,比如:发了 4 个红包,总额为 0.05,则最后一个人领取的红包金额一定是 0.02



无一例外:



所以,红包金额算法大概率不是随机分配,而是在派发红包之前已经做了处理。比如在红包金额生成前,先生成一个不存在的红包,这个红包的总额为 0.01 * 红包总数


而在红包金额分配的时候,会对每个红包的随机值基础上加上 0.01,以此来保证每个红包的最小值不为 0。


所以,假设用户发了总额为 0.04 的个数为 3 的红包时,需要先提取 3*0.01 到 "第四个" 不存在的红包里面,于是第一个人抢到的红包随机值是 0 ~ (0.04-3*0.01)/3


由于担心红包超额,所以除数的商是向下取二位小数,0 ~ (0.04-3*0.01)/3 ==> (0 ~ 0) = 0,再加上之前提取的保底值 0.01,于是前两个抢到的红包金额都是 0.01。最后一个红包的金额为红包余额,即 0.02


算法逻辑用 Go 语言实现如下:


    import (
   "fmt"
   "math"
   "math/rand"
   "strconv"
)

type RedPack struct {
    SurplusAmount float64 // 剩余金额
     SurplusTotal int // 红包剩余个数
}

// 取两位小数
func remainTwoDecimal(num float64) float64 {
    numStr := strconv.FormatFloat(num, 'f'264)
    num, _ = strconv.ParseFloat(numStr, 64)
    return num
}

// 获取随机金额的红包
func getRandomRedPack(rp *RedPack) float64 {
    if rp.SurplusTotal <= 0 {
        // 该红包已经被抢完了
        return 0
    }

    if rp.SurplusTotal == 1 {
        return remainTwoDecimal(rp.SurplusAmount + 0.01)
    }

       // 向下取整
    avgAmount := math.Floor(100*(rp.SurplusAmount/float64(rp.SurplusTotal))) / float64(100)
    avgAmount = remainTwoDecimal(avgAmount)

       // 生成随机数种子
    rand.NewSource(time.Now().UnixNano())

    var max float64
    if avgAmount > 0 {
        max = 2*avgAmount - 0.01
    } else {
        max = 0
    }
    money := remainTwoDecimal(rand.Float64()*(max) + 0.01)

    rp.SurplusTotal -= 1
    rp.SurplusAmount = remainTwoDecimal(rp.SurplusAmount + 0.01 - money)

    return money
}

// 实现主函数
func main() {
    rp := &RedPack{
        SurplusAmount: 0.06,
        SurplusTotal:  5,
    }
    rp.SurplusAmount -= 0.01 * float64(rp.SurplusTotal)
    total := rp.SurplusTotal
    for i := 0; i < total; i++ {
        fmt.Println(getRandomRedPack(rp))
    }
}

打印结果:



0.01、0.01、0.01、0.01、0.02



喜大普奔,符合预期!


5. 总结


设计一个红包系统不仅要考虑海量用户的并发体验和数据一致性,还得保障用户资金的安全


这种技术难点,对于传统的 “秒杀系统” 有过之而无不及。


本文主要探讨了高并发场景下的设计方案和红包分配的算法,覆盖了目前红包系统常见的几大难题。




作者:xin猿意码
来源:juejin.cn/post/7312352501406908452
收起阅读 »

代码要同时推送到 gitee 和 github 该怎么办?教你两招!

前言 我们作为特色社会主义社会中的一份子,跟国外程序员不一样的地方在于,别人能轻松访问 github, 我们却要借梯子,或者用国产的 gitee。 作为有追求的程序员,作为成年人,当然是两个我都要! 那么如何优雅地把本地代码同时维护到 gitee 和 gith...
继续阅读 »

前言


我们作为特色社会主义社会中的一份子,跟国外程序员不一样的地方在于,别人能轻松访问 github, 我们却要借梯子,或者用国产的 gitee


作为有追求的程序员,作为成年人,当然是两个我都要!


那么如何优雅地把本地代码同时维护到 giteegithub上呢?这里带给大家2个方法。


push 2个 remote original


假如我们在 github 创建了一个仓库,那么本地 clone 下来后,主分支(main)是默认跟github 仓库的主分支(main) 关联的。这样直接在 VSCode 里面点 同步的圈圈 就会自动同步,如下图。


image.png


现在我又要推到 gitee,怎么办,很简单,新增一个 remote 源,并命名为 gitee,默认的 origin 已经跟 github 关联了。


git remote add gitee 

这样我们就有2个源,在 VSCodeGit Graph 中可以直接 push 到2个源中,2个都勾选上。


image.png


点击 Yes, push 就可以推上去了。爽歪歪


gitee 仓库镜像管理:gitee -> github


本来上面的方法用得好好的,但是我的梯子质量不好,时不时推不上 github ( gitee 倒是轻轻松松,没有失败过),然后我又得重新 push,有的时候要重试好多次才行,非常浪费时间!!


然后我就发现 gitee 原来是可以同步推到 github 的,根本就不用我们手动操作,点赞!


image.png


官方链接在这里 仓库镜像管理(Gitee<->Github 双向同步),我就不做搬运工了,给几个图:


image.png


image.png


其中还需要到 github 获取 token,方法如下:


image.png


官方链接在这里 如何申请 GitHub 私人令牌?


我设置成功如下图:


image.png



token生成的时候要设置过期时间,尽量设置长一点比如一年。



image.png


推到 gitee 的代码会自动同步到 github, 我们也可以手动点右侧的 更新 按钮,手动同步。


经我检测,是成功的,刚刚推不上去 github 的代码,通过 gitee 同步过去了,本地也能看到代码是同步的。


image.png



有一个需要注意的是,你只能推到你自己的github仓库,不能推到你的github组织的仓库。



以我的仓库为例:



因为选推送的目标仓库时压根就不能选组织,只能选个人!但是源仓库可以是个人的或者组织的。


总结


本文介绍了2种同步 gitee 和 github 仓库的方法,视情况选择:



  • 当目标仓库是个人时,第二种会比较方便,推上到 gitee 后,会自动同步到 github

  • 当目标仓库是组织时,不能用第二种,只能用第一种,自己手动 push 到2个仓库,需要你的梯子质量好,不然可能推不上 github


最后把我每天都看的美女分享给大家~养眼啊


pretty-girl.png


作者:菲鸽
来源:juejin.cn/post/7327353620232339506
收起阅读 »

接手了一个外包开发的项目,我感觉我的头快要裂开了~

嗨,大家好,我是飘渺。 最近,我和小伙伴一起接手了一个由外包团队开发的微服务项目,这个项目采用了当前流行的Spring Cloud Alibaba微服务架构,并且是基于一个“大名鼎鼎”的微服务开源脚手架(附带着模块代码截图,相信很多同学一看就能认出来)。然而,...
继续阅读 »

嗨,大家好,我是飘渺。


最近,我和小伙伴一起接手了一个由外包团队开发的微服务项目,这个项目采用了当前流行的Spring Cloud Alibaba微服务架构,并且是基于一个“大名鼎鼎”的微服务开源脚手架(附带着模块代码截图,相信很多同学一看就能认出来)。然而,在这段时间里,我受到了来自"外包"和"微服务"这双重debuff的折磨。


image-20231016162237399


今天,我想和大家分享一下我在这几天中遇到的问题。希望这几个问题能引起大家的共鸣,以便在未来的微服务开发中避免再次陷入相似的困境。


1、服务模块拆分不合理


绝大部分网上的微服务开源框架都是基于后台管理进行模块拆分的。然而在实际业务开发中,应该以领域建模为基础来划分子服务。


目前的服务拆分方式往往是按照团队或功能来拆分,这种不合理的拆分方式导致了服务调用的混乱,同时增加了分布式事务的风险。


2、微服务拆分后数据库并没拆分


所有服务都共用同一个数据库,这在物理层面无法对数据进行隔离,也导致一些团队为了赶进度,直接读取其他服务的数据表。


这里不禁要问:如果不拆分数据库,那拆分微服务还有何意义?


3、功能复制,不是双倍快乐


在项目中存在一个基础设施模块,其中包括文件上传、数据字典、日志等基础功能。然而,文件上传功能居然在其他模块中重复实现了一遍。就像这样:


image-20231017185809403


4、到处都是无用组件堆彻


在项目的基础模块中,自定义了许多公共的Starter,并且这些组件在各个微服务中被全都引入。比如第三方登录组件、微信支付组件、不明所以的流程引擎组件、验证码组件等等……


image.png


拜托,我们已经有自己的SSO登录,不需要微信支付,还有自己的流程引擎。那些根本用不到的东西,干嘛要引入呢?


5、明显的错误没人解决


这个问题是由上面的问题所导致的,由于引入了一个根本不需要的消息中间件,项目运行时不断出现如下所示的连接异常。


image-20231013223714103


项目开发了这么久,出错了这么久,居然没有一个人去解决,真的让人不得不佩服他们的忍受力。


6、配置文件一团乱麻


你看到服务中这一堆配置文件,是不是心里咯噔了一下?


image-20231017190214587


或许有人会说:"没什么问题呀,按照不同环境划分不同的配置文件”。可是在微服务架构下,已经有了配置中心,为什么还要这么做呢?这不是画蛇添足吗?


7、乱用配置中心


项目一开始就明确要使用Apollo配置中心,一个微服务对应一个appid,appid一般与application.name一致。


但实际上,多个服务却使用了相同的appid,多个服务的配置文件还塞在了同一个appid下。


更让人费解的是,有些微服务又不使用配置中心。


8、Nacos注册中心混乱


由于项目有众多参与的团队,为了联调代码,开发人员在启动服务时不得不修改配置文件中Nacos的spring.cloud.nacos.discovery.group属性,同时需要启动所有相关服务。


这导致了两个问题:一是某个用户提交了自己的配置文件,导致其他人的服务注册到了别的group,影响他人的联调;二是Nacos注册中心会存在一大堆不同的Gr0up,查找服务变得相当麻烦。


其实要解决这个问题只需要重写一下网关的负载均衡策略,让流量调度到指定的服务即可。据我所知,他们使用的开源框架应该支持这个功能,只是他们不知道怎么使用。


9、接口协议混乱


使用的开源脚手架支持Dubbo协议和OpenFeign调用,然而在我们的项目中并不会使用Dubbo协议,微服务之间只使用OpenFeign进行调用。然而,在对外提供接口时,却暴露了一堆支持Dubbo协议的接口。


10、部署方式混乱


项目部署到Kubernetes云环境,一般来说,服务部署到云上的内部服务应该使用ClusterIP的方式进行部署,只有网关服务需要对外访问,网关可以通过NodePort或Ingress进行访问。


这样做可以避免其他人或服务绕过网关直接访问后端微服务。


然而,他们的部署方式是所有服务都开启了NodePort访问,然后在云主机上还要部署一套Nginx来反向代理网关服务的NodePort端口。


image-20231016162150035


结语


网络上涌现着众多微服务开源脚手架,它们吸引用户的方式是将各种功能一股脑地集成进去。然而,它们往往只是告诉你“如何集成”却忽略了“为什么要集成”。


尽管这些开源项目能够在学习微服务方面事半功倍,但在实际微服务项目中,我们不能盲目照搬,而应该根据项目的实际情况来有选择地裁剪或扩展功能。这样,我们才能更好地应对项目的需求,避免陷入不必要的复杂性,从而更加成功地实施微服务架构。


最后,这个开源项目你们认识吗?


image-20231017190633190


作者:飘渺Jam
来源:juejin.cn/post/7291480666087964732
收起阅读 »

localhost和127.0.0.1的区别是什么?

今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个: localhost和127.0.0.1的区别是什么? 前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显...
继续阅读 »

今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个:


localhost和127.0.0.1的区别是什么?



前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显示的就是这个 http://localhost:xxx/index.html


可能大家只是用,也没有去想过这个问题。


联想到我之前合作过的一些开发同学对它们俩的区别也没什么概念,所以我觉得有必要普及下。


localhost是什么呢?


localhost是一个域名,和大家上网使用的域名没有什么本质区别,就是方便记忆。


只是这个localhost的有效范围只有本机,看名字也能知道:local就是本地的意思。


张三和李四都可以在各自的机器上使用localhost,但获取到的也是各自的页面内容,不会相互打架。


从域名到程序


要想真正的认清楚localhost,我们还得从用户是如何通过域名访问到程序说起。


以访问百度为例。


1、当我们在浏览器输入 baidu.com 之后,浏览器首先去DNS中查询 baidu.com 的IP地址。


为什么需要IP地址呢?打个比方,有个人要寄快递到你的公司,快递单上会填写:公司的通讯地址、公司名称、收件人等信息,实际运输时快递会根据通信地址进行层层转发,最终送到收件人的手中。网络通讯也是类似的,其中域名就像公司名称,IP地址就像通信地址,在网络的世界中只有通过IP地址才能找到对应的程序。


DNS就像一个公司黄页,其中记录着每个域名对应的IP地址,当然也有一些域名可能没做登记,就找不到对应的IP地址,还有一些域名可能会对应多个IP地址,DNS会按照规则自动返回一个。我们购买了域名之后,一般域名服务商会提供一个域名解析的功能,就是把域名和对应的IP地址登记到DNS中。


这里的IP地址从哪里获取呢?每台上网的电脑都会有1个IP地址,但是个人电脑的IP地址一般是不行的,个人电脑的IP地址只适合内网定位,就像你公司内部的第几栋第几层,公司内部人明白,但是直接发给别人,别人是找不到你的。如果你要对外部提供服务,比如百度这种,你就得有公网的IP地址,这个IP地址一般由网络服务运营商提供,比如你们公司使用联通上网,那就可以让联通给你分配一个公网IP地址,绑定到你们公司的网关服务器上,网关服务器就像电话总机,公司内部的所有网络通信都要通过它,然后再在网关上设置转发规则,将网络请求转发到提供网络服务的机器上。


2、有了IP地址之后,浏览器就会向这个IP地址发起请求,通过操作系统打包成IP请求包,然后发送到网络上。网络传输有一套完整的路由协议,它会根据你提供的IP地址,经过路由器的层层转发,最终抵达绑定该IP的计算机。


3、计算机上可能部署了多个网络应用程序,这个请求应该发给哪个程序呢?这里有一个端口的概念,每个网络应用程序启动的时候可以绑定一个或多个端口,不同的网络应用程序绑定的端口不能重复,再次绑定时会提示端口被占用。通过在请求中指定端口,就可以将消息发送到正确的网络处理程序。


但是我们访问百度的时候没有输入端口啊?这是因为默认不输入就使用80和443端口,http使用80,https使用443。我们在启动网络程序的时候一定要绑定一个端口的,当然有些框架会自动选择一个计算机上未使用的端口。



localhost和127.0.0.1的区别是什么?


有了上边的知识储备,我们就可以很轻松的搞懂这个问题了。


localhost是域名,上文已经说过了。


127.0.0.1 呢?是IP地址,当前机器的本地IP地址,且只能在本机使用,你的计算机不联网也可以用这个IP地址,就是为了方便开发测试网络程序的。我们调试时启动的程序就是绑定到这个IP地址的。


这里简单说下,我们经常看到的IP地址一般都是类似 X.X.X.X 的格式,用"."分成四段。其实它是一个32位的二进制数,分成四段后,每一段是8位,然后每一段再转换为10进制的数进行显示。


那localhost是怎么解析到127.0.0.1的呢?经过DNS了吗?没有。每台计算机都可以使用localhost和127.0.0.1,这没办法让DNS来做解析。


那就让每台计算机自己解决了。每台计算机上都有一个host文件,其中写死了一些DNS解析规则,就包括 localhost 到 127.0.0.1 的解析规则,这是一个约定俗成的规则。


如果你不想用localhost,那也可以,随便起个名字,比如 wodehost,也解析到 127.0.0.1 就行了。


甚至你想使用 baidu.com 也完全可以,只是只能自己自嗨,对别人完全没有影响。


域名的等级划分


localhost不太像我们平常使用的域名,比如 http://www.juejin.cn 、baidu.com、csdn.net, 这里边的 www、cn、com、net都是什么意思?localhost为什么不需要?


域名其实是分等级的,按照等级可以划分为顶级域名、二级域名和三级域名...


顶级域名(TLD):顶级域名是域名系统中最高级别的域名。它位于域名的最右边,通常由几个字母组成。顶级域名分为两种类型:通用顶级域名和国家顶级域名。常见的通用顶级域名包括表示工商企业的.com、表示网络提供商的.net、表示非盈利组织的.org等,而国家顶级域名则代表特定的国家或地区,如.cn代表中国、.uk代表英国等。


二级域名(SLD):二级域名是在顶级域名之下的一级域名。它是由注册人自行选择和注册的,可以是个性化的、易于记忆的名称。例如,juejin.cn 就是二级域名。我们平常能够申请到的也是这种。目前来说申请 xxx.com、xxx.net、xxx.cn等等域名,其实大家不太关心其顶级域名com\net\cn代表的含义,看着简短好记是主要诉求。


三级域名(3LD):三级域名是在二级域名之下的一级域名。它通常用于指向特定的服务器或子网。例如,在blog.example.com中,blog就是三级域名。www是最常见的三级域名,用于代表网站的主页或主站点,不过这只是某种流行习惯,目前很多网站都推荐直接使用二级域名访问了。


域名级别还可以进一步细分,大家可以看看企业微信开放平台这个域名:developer.work.weixin.qq.com,com代表商业,qq代表腾讯,weixin代表微信,work代表企业微信,developer代表开发者。这种逐层递进的方式有利于域名的分配管理。


按照上边的等级定义,我们可以说localhost是一个顶级域名,只不过它是保留的顶级域,其唯一目的是用于访问当前计算机。


多网站共用一个IP和端口


上边我们说不同的网络程序不能使用相同的端口,其实是有办法突破的。


以前个人博客比较火的时候,大家都喜欢买个虚拟主机,然后部署个开源的博客程序,抒发一下自己的感情。为了挣钱,虚拟主机的服务商会在一台计算机上分配N多个虚拟主机,大家使用各自的域名和默认的80端口进行访问,也都相安无事。这是怎么做到的呢?


如果你有使用Nginx、Apache或者IIS等Web服务器的相关经验,你可能会接触到主机头这个概念。主机头其实就是一个域名,通过设置主机头,我们的程序就可以共用1个网络端口。


首先在Nginx等Web程序中部署网站时,我们会进行一些配置,此时在主机头中写入网站要使用的域名。


然后Nginx等Web服务器启动的时候,会把80端口占为己有。


然后当某个网站的请求到达Nginx的80端口时,它会根据请求中携带的域名找到配置了对应主机头的网络程序。


然后再转发到这个网络程序,如果网络程序还没有启动,Nginx会把它拉起来。


私有IP地址


除了127.0.0.1,其实还有很多私有IP地址,比如常见的 192.168.x.x。这些私有IP地址大部分都是为了在局域网内使用而预留的,因为给每台计算机都分配一个独立的IP不太够用,所以只要局域网内不冲突,大家就可劲的用吧。你公司可以用 192.168.1.1,我公司也可以用192.168.1.1,但是如果你要访问我,就得通过公网IP进行转发。


大家常用的IPv4私有IP地址段分为三类:


A类:从10.0.0.0至10.255.255.255


B类:从172.16.0.0至172.31.255.255


C类:从192.168.0.0至192.168.255.255。


这些私有IP地址仅供局域网内部使用,不能在公网上使用。


--


除了上述三个私有的IPv4地址段外,还有一些保留的IPv4地址段:


用于本地回环测试的127.0.0.0至127.255.255.255地址段,其中就包括题目中的127.0.0.1,如果你喜欢也可以给自己分配一个127.0.0.2的IP地址,效果和127.0.0.1一样。


用于局域网内部的169.254.0.0至169.254.255.255地址段,这个很少接触到,如果你的电脑连局域网都上不去,可能会看到这个IP地址,它是临时分配的一个局域网地址。


这些地址段也都不能在公网上使用。


--


近年来,还有一个现象,就是你家里或者公司里上网时,光猫或者路由器对外的IPv4地址也不是公网IP了,这时候获得的可能是一个类似 100.64.x.x 的地址,这是因为随着宽带的普及,运营商手里的公网IP也不够了,所以运营商又加了一层局域网,而100.64.0.0 这个网段是专门分给运营商做局域网用的。如果你使用阿里云等公有云,一些云产品的IP地址也可能是这个,这是为了将客户的私有网段和公有云厂商的私有网段进行有效的区分。


--


其实还有一些不常见的专用IPv4地址段,完整的IP地址段定义可以看这里:http://www.iana.org/assignments…



IPv6


你可能也听说过IPv6,因为IPv4可分配的地址太少了,不够用,使用IPv6甚至可以为地球上的每一粒沙子分配一个IP。只是喊了很多年,大家还是喜欢用IPv4,这里边原因很多,这里就不多谈了。


IPv6地址类似:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX

它是128位的,用":"分成8段,每个X是一个16进制数(取值范围:0-F),IPv6地址空间相对于IPv4地址有了极大的扩充。比如:2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b 就是一个有效的IPv6地址。


关于IPv6这里就不多说了,有兴趣的可以再去研究下。


关注萤火架构,加速技术提升!


作者:萤火架构
来源:juejin.cn/post/7321049446443417638
收起阅读 »

rpc比http好吗,缪论?

是什么,如何理解 RPC(Remote Procedure Call) 直译就是远程过程调用 HTTP(HyperText Transfer Protorl) 直译就是超文本传输协议 RPC和HTTP都是 请求-响应协议,但是因为出现的时机、设计理念、约定协议...
继续阅读 »

是什么,如何理解


RPC(Remote Procedure Call) 直译就是远程过程调用


HTTP(HyperText Transfer Protorl) 直译就是超文本传输协议


RPC和HTTP都是 请求-响应协议,但是因为出现的时机、设计理念、约定协议、效率、应用范围、使用规则等不同,所以是不同的名字,本质都是为了分布式系统间的通信而生,是一种应用层通信(请求-响应)协议(从OSI网络模型来看)。



  • RPC是 Bruce Jay Nelson 在1981年创造的术语,HTTP是在1990年左右产生的(可以参看维基百科)


RPC协议 和 RPC,到底叫什么?RPC协议=RPC


HTTP协议、HTTP,到底叫什么?HTTP协议=HTTP


RPC|HTTP只是大家的简称



  • HTTP协议不仅仅只有协议,还有超文本,传输,以及很多功能(比如编解码、面试经常背的各种参数的作用)

  • RPC协议也不仅仅只有协议,还有 编解码,服务注册发现,负载均衡等


RPC协议本质上定义了一种通信的流程,而具体的实现技术是没有约束的,每一种RPC框架都有自己的实现方式,我认为HTTP也是RPC的一种实现方式


协议直白来讲是一种约定,rpc和http都是为了服务器间的通信而生,都需要制定一套标准协议来进行通信。不过HTTP比较火,是一个全世界的统一约定,使用比较广泛。但通用也意味着冗余,所以后来又产生了很多RPC框架(自定义协议,具备优秀的性能等)


我们可以自定义RPC请求/响应 包含的消息头和消息体结构,自定义编解码方式,自定义网络通信方式,只要clientserver消息的发送和解析能对应即可,这些问题确认下来,一个RPC框架就设计出来了


下面先从请求过程看一下RPC和HTTP都会经历哪些阶段,然后再分阶段去做对比


一次请求的过程



阶段阶段分层RPCHTTP
client: 业务逻辑xx业务逻辑层
client: 客户端构造请求,发起调用编解码thrift|json|protobuf等json|图片等
client: 根据传输协议构造数据流协议层thrift|gRPC|Kitex|dubbo等HTTP1 |HTTP1.1|HTTP2|QUIC等
client: 服务发现服务发现自定义内部服务发现组件DNS
client: 网络通信:传输数据流网络通信层接口层:netty|netpool,根据OS的API做了一些封装本质:TCP|UDP|HTTP系列接口层:HTTP内部自己实现,目前不清楚咋做的本质:TCP|UDP
server: 把数据流解析为协议结构协议层略,同上略,同上
server: 解析协议中的请求体编解码略,同上略,同上
server: 执行业务逻辑xx业务逻辑层略,同上略,同上

从请求链路可以看到,最核心的只有三层:编解码、协议、网络通信


下面会从这3个角度去对比HTTP和RPC


HTTP VS RPC自定义协议


HTTP和RPC 2个关键词不具备可比较性,因为RPC包含了HTTP。


但是RPC自定义协议(thrift, protobuf, dubbo, kitex-thrift等) 是RPC的具体实现,HTTP也是RPC的具体实现,它们是具备可比较性的


编解码(序列化)



  • 序列化: 指将程序运行过程中的动态内存数据(java的class、go的struct)转化为硬盘中静态二进制数据的过程,以方便网络传输。

  • 反序列化:指将硬盘中静态二进制数据转化为程序运行过程中的动态内存数据的过程,以方便程序计算。


HTTP/1.1 一般用json


自定义RPC协议 一般用 thrift、protobuf


kitex序列化协议


维度json(HTTP/1.1)protobuf(gRPC)
优点1. 可读性好、使用简单,学习成本低1. 序列化后的体积比json小 => 传输效率高
2. 序列化/反序列化速度快 => 性能损耗小
缺点1. JSON 进行序列化的额外空间开销比较大
2. JSON 没有类型,比如无法区分整数和浮点
像 Java 、Go这种强类型语言,不是很友好,解析速度比较慢(需要通过反射解决)
1. 不可读,都是二进制
适用场景适用于服务提供者与服务调用者之间传输的数据量要相对较小的情况,否则会严重影响性能追求高性能的场景

协议层


编码之后,数据转换成字节流,但是RPC通信时,每次请求发送的数据大小不是固定的,那么为了区分消息的边界,避免粘包、半包等现象,我们需要定义一种协议,来使得接收方能够正确地读出不定长的内容。简单点说,通信协议就是约定客户端和服务器端传输什么数据,以及如何解析数据。


维度HTTP/1.1kitex-TTHeader
优点1. 灵活,可以自定义很多字段
2. 几乎所有设备都可以支持HTTP协议
1. 灵活,通用,可以自定义
  • 自定义必要字段即可 => 减小报文体积,提高传输效率
    2. 性能优秀
  • 缺点1. 包含许多为了适应浏览器的冗余字段,这些是内部服务用不到的,性能差1. 部分设备存在不能支持,通用性欠佳

    可参考



    可以思考一下 序列化、传输协议、网络通信的关系,下面以kitex为例进行分析


    kitex codec 接口定义kitex thrift 序列化实现kitex ttheader协议,kitex 发送请求核心代码



    可以发现 Encode中,先根据message构造出header,写入out,然后再把data(实际的业务数据)写到out。


    encode函数完全遵守 ttheader协议去构造数据。


    最后再把out通过网络库发送出去



    网络通信层


    网络通信层主要提供一个易用的网络库,封装了操作系统提供的socket api。


    维度HTTP/1.1kitex框架
    实现方式一般采用短连接需要3次握手(可以配置长链接添加请求头Keep-Alive: timeout=20)- 长连接,指在一个连接上可以连续发送多个数据包,在连接保持期间,如果没有数据包发送,需要双方发链路检测包rpc框架维护一个tcp连接池,每次用完不断开连接,通过心跳检测断开连接(探测服务是否有问题)- 支持短连接、长连接池、连接多路复用以及连接池状态监控。
    优点1. 几乎所有设备都可以支持HTTP协议1. 不用每次请求都经历三次握手&四次挥手,减少延时
    缺点1. 每次请求都要新建连接,性能差1. 部分设备存在不能支持,通用性欠佳

    HTTP的长连接和TCP长连接不是一个东西,需要注意下,TCP Keepalive是操作系统实现的功能,并不是TCP协议的一部分,需要在操作系统下进行相关配置(只能保证网络没问题,不能代表服务没问题)


    其中 HTTP2 拥有多路复用、优先级控制、头部压缩等优势


    可以参考


    kitex:连接类型


    RPC自定义协议 和 HTTP的使用场景


    公司内部的微服务,对客户端提供的服务 适合用RPC,更好的性能


    对外服务、单体服务、为前端提供的服务适合用HTTP


    我的思考


    rpc在编解码、协议层、网络通信 都比HTTP有更大的优势,那为啥不把HTTP换成RPC呢



    1. 人的认知,HTTP已经深入人心(或者说生态好,通用性强),几乎所有的机器、浏览器和语言默认都会支持。但是自定义RPC协议 可能很多人都没听过(比如kitex、dubbo等),还让别人支持,根本不可能。

      • 需要建设全局的DNS等等,HTTP链路中的组件都需要换成 自定义的那一套,成本极高。

      • 但是公司内部可以搞成一套,可以极大提高性能,何乐而不为。

      • 我见过的案例是很多时候并没有深入思考为什么用,而是大家都这么用,我也这么用。



    2. 浏览器只支持 http协议。而且浏览器不支持自定义编解码的解析

      • 为啥大家面向浏览器/前端 不用自定义编解码?

        • 举例:protobuf不支持前端语言,但是支持java

        • 就是自定义编解码框架支持语言有限,很多语言没有工具可以做,并且浏览器也不支持。对于问题排查比较困难。

        • github.com/protocolbuf…



      • http不仅可以传输json、还可以传输二进制、图片等。所以协议层可以用http,编解码用protobuf/thrift也是可行的。

        • 公司内部实际案例:服务端和客户端交互时,为了提高性能,采用protobuf编解码数据,使用http协议传输数据。

          • 但是每次请求/响应数据都是不可读的。服务端会把protobuf编码前的数据转为json,用于打印log/存储,方便排查问题。





      • 参考 丨隋堤倦客丨的评论





    • RPC框架 可以自定义负载均衡,重试机制,高可用,流量控制等策略。这些是HTTP不能支持的

      • 我理解是协议层用的http,但是内部的运行机制还是自定义的。http只是定义了传输数据的格式。举个例子:http的流量控制其实用的是 tcp的滑动窗口,http协议本身不具备这些功能。但是rpc是可以自己加这些功能的。这些功能必然有数据传输,这个传输协议用的http。

      • 参考 leewp同学的评论




    参考


    如何保活主流RPC框架长连接,Dubbo的心跳机制,值得学习_牛客博客


    3.8 既然有 HTTP 协议,为什么还要有 RPC?


    4.15 TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?


    RPC 漫谈: 连接问题


    聊一聊Go网络编程(一)--TCP连接通信 - 掘金


    Kitex前传:RPC框架那些你不得不知的故事


    kitex 传输协议


    dubbo RPC 协议


    作者:cli
    来源:juejin.cn/post/7264454873588449336
    收起阅读 »

    代码字体 ugly?试试这款高颜值代码字体

    Monaspace 是有 GitHub 开源的代码字体,包含 5 种变形字体的等宽代码字体家族,颜值 Up,很难不喜欢。 来看一下这 5 种字体分别是: 1️⃣ Radon 手写风格字体 2️⃣ Krypton 机械风格字体 3️⃣ Xenon 衬线风格字...
    继续阅读 »

    Monaspace 是有 GitHub 开源的代码字体,包含 5 种变形字体的等宽代码字体家族,颜值 Up,很难不喜欢。


    来看一下这 5 种字体分别是:


    1️⃣ Radon 手写风格字体



    2️⃣ Krypton 机械风格字体

    3️⃣ Xenon 衬线风格字体



    4️⃣ Argon 人文风格字体



    5️⃣ Neon 现代风格字体



    👉 项目地址:github.com/githubnext/…


    下载方式


    MacOS


    使用 brew 安装:


    brew tap homebrew/cask-fonts
    brew install font-monaspace

    Windows


    下载该文件:github.com/githubnext/…


    拖到 C:\Windows\Fonts 中,点击安装


    下载好后,如果是 VSCode 文件,可以在设置中找到 font-family,改为:'Monaspace Radon', monospace





    作者:吴楷鹏
    来源:juejin.cn/post/7332435905925562418
    收起阅读 »

    系统干崩了,只认代码不认人

    各位朋友听我一句劝,写代码提供方法给别人调用时,不管是内部系统调用,还是外部系统调用,还是被动触发调用(比如MQ消费、回调执行等),一定要加上必要的条件校验。千万别信某些同事说的这个条件肯定会传、肯定有值、肯定不为空等等。这不,临过年了我就被坑了一波,弄了个生...
    继续阅读 »

    各位朋友听我一句劝,写代码提供方法给别人调用时,不管是内部系统调用,还是外部系统调用,还是被动触发调用(比如MQ消费、回调执行等),一定要加上必要的条件校验。千万别信某些同事说的这个条件肯定会传、肯定有值、肯定不为空等等。这不,临过年了我就被坑了一波,弄了个生产事故,年终奖基本是凉了半截。


    为了保障系统的高可用和稳定,我发誓以后只认代码不认人。文末总结了几个小教训,希望对你有帮助。


    一、事发经过


    我的业务场景是:业务A有改动时,发送MQ,然后应用自身接受到MQ后,再组合一些数据写入到Elasticsearch。以下是事发经过:



    1. 收到一个业务A的异常告警,当时的告警如下:



    2. 咋一看觉得有点奇怪,怎么会是Redis异常呢?然后自己连了下Redis没有问题,又看了下Redis集群,一切正常。所以就放过了,以为是偶然出现的网络问题。

    3. 然后技术问题群里 客服 反馈有部分用户使用异常,我警觉性的感觉到是系统出问题了。赶紧打开了系统,确实有偶发性的问题。

    4. 于是我习惯性的看了几个核心部件:



      1. 网关情况、核心业务Pod的负载情况、用户中心Pod的负载情况。

      2. Mysql的情况:内存、CPU、慢SQL、死锁、连接数等。



    5. 果然发现了慢SQL和元数据锁时间过长的情况。找到了一张大表的全表查询,数据太大,执行太慢,从而导致元数据锁持续时间太长,最终数据库连接数快被耗尽。


    SELECT xxx,xxx,xxx,xxx FROM 一张大表


    1. 立马Kill掉几个慢会话之后,发现系统仍然没有完全恢复,为啥呢?现在数据库已经正常了,怎么还没完全恢复呢?又继续看了应用监控,发现用户中心的10个Pod里有2个Pod异常了,CPU和内存都爆了。难怪使用时出现偶发性的异常呢。于是赶紧重启Pod,先把应用恢复。

    2. 问题找到了,接下来就继续排查为什么用户中心的Pod挂掉了。从以下几个怀疑点开始分析:



      1. 同步数据到Elasticsearch的代码是不是有问题,怎么会出现连不上Redis的情况呢?

      2. 会不会是异常过多,导致发送异常告警消息的线程池队列满了,然后就OOM?

      3. 哪里会对那张业务A的大表做不带条件的全表查询呢?



    3. 继续排查怀疑点a,刚开始以为:是拿不到Redis链接,导致异常进到了线程池队列,然后队列撑爆,导致OOM了。按照这个设想,修改了代码,升级,继续观察,依旧出现同样的慢SQL 和 用户中心被干爆的情况。因为没有异常了,所以怀疑点b也可以被排除了。

    4. 此时基本可以肯定是怀疑点c了,是哪里调用了业务A的大表的全表查询,然后导致用户中心的内存过大,JVM来不及回收,然后直接干爆了CPU。同时也是因为全表数据太大,导致查询时的元数据锁时间过长造成了连接不能够及时释放,最终几乎被耗尽。

    5. 于是修改了查询业务A的大表必要校验条件,重新部署上线观察。最终定位出了问题。


    二、问题的原因


    因为在变更业务B表时,需要发送MQ消息( 同步业务A表的数据到ES),接受到MQ消息后,查询业务A表相关连的数据,然后同步数据到Elasticsearch。


    但是变更业务B表时,没有传业务A表需要的必要条件,同时我也没有校验必要条件,从而导致了对业务A的大表的全表扫描。因为:


    某些同事说,“这个条件肯定会传、肯定有值、肯定不为空...”,结果我真信了他!!!

    由于业务B表当时变更频繁,发出和消费的MQ消息较多,触发了更多的业务A的大表全表扫描,进而导致了更多的Mysql元数据锁时间过长,最终连接数消耗过多。


    同时每次都是把业务A的大表查询的结果返回到用户中心的内存中,从而触发了JVM垃圾回收,但是又回收不了,最终内存和CPU都被干爆了。


    至于Redis拿不到连接的异常也只是个烟雾弹,因为发送和消费的MQ事件太多,瞬时间有少部分线程确实拿不到Redis连接。


    最终我在消费MQ事件处的代码里增加了条件校验,同时也在查询业务A表处也增加了的必要条件校验,重新部署上线,问题解决。


    三、总结教训


    经过此事,我也总结了一些教训,与君共勉:



    1. 时刻警惕线上问题,一旦出现问题,千万不能放过,赶紧排查。不要再去怀疑网络抖动问题,大部分的问题,都跟网络无关。

    2. 业务大表自身要做好保护意识,查询处一定要增加必须条件校验。

    3. 消费MQ消息时,一定要做必要条件校验,不要相信任何信息来源。

    4. 千万别信某些同事说,“这个条件肯定会传、肯定有值、肯定不为空”等等。为了保障系统的高可用和稳定,咱们只认代码不认人

    5. 一般出现问题时的排查顺序:



      1. 数据库的CPU、死锁、慢SQL。

      2. 应用的网关和核心部件的CPU、内存、日志。



    6. 业务的可观测性和告警必不可少,而且必须要全面,这样才能更快的发现问题和解决问题。




    作者:不焦躁的程序员
    来源:juejin.cn/post/7331628641360248868
    收起阅读 »

    记录一次我们的PostgreSQL数据库被攻击了

    数据库所有表被删除了 这个程序员把我们的数据库表都删除了,然后新建了一个数据库redme_to_recover数据库 里面还有一张表,表里是让你支付,然后给你数据下载地址。 通过查看Docker里部署的PostgreSQL执行日志是没有操作记录的 根据数据...
    继续阅读 »

    数据库所有表被删除了


    微信图片_20240126160520.png


    这个程序员把我们的数据库表都删除了,然后新建了一个数据库redme_to_recover数据库


    里面还有一张表,表里是让你支付,然后给你数据下载地址。


    通过查看Docker里部署的PostgreSQL执行日志是没有操作记录的


    微信图片_20240126162925.png


    根据数据库的日志确定,1月24号13点数据库被重启了。


    25号的日志非常少,错误信息都是客户端连接失败,无法从客户端接收数据。(25号系统还是正常的)


    26号02时的日志就显示tdd表没了(这时候应该是所有表都没了)。


    中间没有删除表的操作日志,跟大佬请教了一下,确定应该是有人登录了我们的Linux系统。然后从Linux系统层面直接删除的表资源数据,没有通过PGSQL操作,没有删除操作记录。


    我对黑客攻击的数据库进行了修改密码,然后发现密码失效了,无论输入什么密码,都能正常登录数据库。


    我是怎么恢复的


    1、将原来的PG数据库镜像删除,重新修改了端口号和数据库密码然后启动数据库容器。


    docker ps -a 列出所有的Docker容器,包括正在运行和已经停止的容器。


    docker rm [容器id/容器名称] 删除PostgreSQL容器。


    docker run 启动一个新的容器。
    image.png


    2、将Linux账户登录密码修改。


    3、修改端口号和数据库配置密码后,重新打包我们的数据处理程序。


    4、修改Nacos里配置的接口服务程序的数据库连接配置。


    5、将表结构恢复,系统表和业务表结构,系统表包括账户角色等信息(幸亏我们同事有备份)


    6、丢失了历史业务数据


    image.png


    作者:悟空啊
    来源:juejin.cn/post/7328003589297291276
    收起阅读 »

    可视化 Java 项目

    有一定规模的 IT 公司,只要几年,必然存在大量的代码,比如腾讯,2019 年一年增加 12.9 亿行代码,现在只会更多。不管是对于公司,还是对于个人,怎么低成本的了解这些代码的对应业务,所提供的能力,都是非常有必要的! 今天,阿七就带大家破解这个难题,根据这...
    继续阅读 »

    有一定规模的 IT 公司,只要几年,必然存在大量的代码,比如腾讯,2019 年一年增加 12.9 亿行代码,现在只会更多。不管是对于公司,还是对于个人,怎么低成本的了解这些代码的对应业务,所提供的能力,都是非常有必要的!


    今天,阿七就带大家破解这个难题,根据这个文档,你能使用 AI 编程技术,根据包含 Java 完整代码的项目实现可视化下面三个方面的内容:



    • 模块和功能:应用内部的业务模块和功能,及相互间的关系,为用户提供应用的整体视图。

    • 类和接口:应用模块提供的业务能力以及对应的类和接口,以及接口对应业务流程语义化。

    • 方法实现语义化:方法实现逻辑的语义化和可视化;


    一、先秀一下成果


    一)Java 项目概览图


    根据一个 Java 项目,可以生成下面这样的项目整体概览图,对于不需要了解实现细节的产品、运营同学,直接看这个图,就能够了解这个 Java 项目在干什么、能提供什么能力。


    对于部分技术同学,不需要了解代码详情的,也可以直接看这个图即可。满足新入职同学对于接手不常变更项目的理解和全局业务的了解!


    PS:由于保密需要,所有的成果图仅为示例图。实际的图会更好看、更震撼,因为一个 Java 项目的功能模块可能很多,提供的能力可能很多。



    对于需要了解技术细节的同学,点击入口,能看到当前方法的流程图,快速了解当前方法提供的能力,具体的细节。还能迅速发现流程上可能存在的问题,快速纠正。


    二)具体方法流程图



    有了上面的两层可视化图表,不管是产品、技术、测试、运营以及小领导,都能快速的根据一个 Java 项目获取到他所需要的层级的信息,降低开发人员通过阅读代码梳理业务逻辑和代码逻辑的时间,尤其是新入职的同学。这个时间据统计,基本上在 25%-30%(百度、阿里等大公司调研数据更大,为 55%-60%),对于新同学,这个比例会更大!


    二、实现步骤


    一)整体概述图怎么生成?


    一个 Java 项目所有对外接口在做的事情,就是一个 Java 项目的核心业务。这个对外接口包括:HTTP 接口、Dubbo 接口、定时任务。


    1、获取一个 Java 项目所有对外接口


    1)通过 Trace 平台


    可以查询到一个 Java 项目所有对外的 HTTP 接口和 Dubbo 接口,通过注解可以查询一个 Java 项目所有定时任务。


    优点:



    • 数据准确,跑出来的数据,一定是还在用的接口;
      缺点:

    • 需要依赖 Trace 平台数据,部分公司可能没有 Trace 平台。


    2)通过 JavaParser 工具


    可以通过 JavaParser 工具,扫描整个 Java 项目代码。找到所有的对外入口。


    优点:



    • 不依赖 Trace 数据;
      缺点:

    • 可能不准确,因为有些接口已经不被使用了。


    2、获取对外接口的方法内容


    1)根据 HTTP 的接口 url 可以反解析出来这个 url 对应的方法的全路径。


    具体来说,在项目中获取 Spring 上下文,Spring 上下文中有一个 Bean 叫 RequestMappingHandlerMapping,这个 Bean 中提供了一个方法 getHandlerMethods,这个方法中保存了一个 Java 项目中所有的对外 HTTP 方法。


    这个方法返回一个 Map对象,key 是 HTTP 接口的 URL,value 就是这个 URL 对应方法的全路径名称。



    2)根据方法全路径,获取方法内容


    根据上面的全路径名,使用 Spoon 框架我们能拿到对应方法的方法体。



    fr.inria.gforge.spoon
    spoon-core


    我们让 ChatGPT 帮我们写代码,提示词:



    写一个 Java 方法,使用 Spoon 框架解析 Java 方法的完整内容
    其中入参是方法全路径名




    PS:这个代码一会还有用,我们往下递归的话,能拿到这个 Controller 方法调用的所有方法体。


    3、根据方法内容生成方法注释


    就和 GitHub Copilot 和百度 Comate 代码助手一样,GPT 可以根据代码生成方法注释,提示词:



    角色: 你是一个 Java 技术专家。

    任务: # 号开头的是一个 Java 方法。请你逐行阅读代码,然后为这个 Java 方法生成一句话注释。

    限制:不要超过 20 个字



    举个例子,我有个工具方法,使用 GPT 为他生成注释,如下:



    4、生成 Java 项目一句话描述



    角色: 你是一个 Java 技术专家。

    任务: --- 符号以上的是一个 Java 项目中所有对外方法的注释,请你逐行阅读这些注释,然后给这个 Java 项目生成一句话描述。

    限制: 结果不要超过两句话。



    这个利用的是 GPT 的总结概要的能力,GPT 能总结论文、总结文章,他也能总结一段描述 Java 项目的文字。这样就能获取对于一个 Java 项目的一句话描述,也就是项目概览图的第一层。


    5、总结:生成项目概览图


    我们要求 GPT 根据 Java 项目的一句话描述,和所有对完方法的方法注释,生成思维导图数据。为了项目概览图的层级更可读、更清晰,我们可以要求 GPT 根据方法注释的相似性进行分类,形成项目概览图的第二层。第三层就是所有项目中对外方法的注释。


    生成思维导图,可以让 GPT 根据结构内容生成 puml 格式的思维导图数据,我们把 puml 格式的数据存储为 puml 文件,然后使用 xmind 或者在线画图工具 processOn 打开就能看到完整的思维导图。


    参考提示词如下:



    应用代码:appCodeValue

    项目描述:appCodeDescValue

    项目描述:appCodeDescValue

    方法描述:methodDescListValue

    角色:你是一个有多年经验的 Java 技术专家,在集成 Java 项目方面有丰富的经验。

    任务:根据 Java 项目中所有公共接口的描述信息生成思维导图。

    要求:思维导图只有四个层级。

    详细要求:思维导图的中心主题是 appCodeValue,第一层分支是 appCodeDescValue;第二层分支是公共接口的分类;下层分支是每个分类下方法的描述信息。

    返回正确格式的 opml 思维导图 xml 数据,并且内容是中文。



    二)流程图怎么生成?


    1、获取递归代码


    直接问 GPT,让 GPT 改造上面的获取方法体的方法。


    prompt;



    {获取方法体的方法}

    上面的 Java 代码是使用 Spoon 框架解析 Java 方法的完整内容
    其中入参是方法全路径名

    任务:现在要求你改造这个方法,除了打印当前方法的完整内容,还要求递归打印所有调用方法的方法体内容,包含被调用方法调用的方法





    这样,我们能获取到一个 controller 方法所有递归调用的方法,每个方法生成自己的流程图,最后通过流程图嵌套的形式进行展示。


    比如这个例子,当前能看到的是当前方法的流程图,带 + 号的内容,是当前方法调用方法的流程图。这样方便我们按照自己需要的深度去了解当前方法的具体实现流程!


    2、无效代码剪枝


    按照上面生成的流程图可能分支很多,还有一些无效的信息,影响用户判断,我们可以通过删除一些业务无关代码的方法,精简流程图。


    比如,我们可以删除日志、监控等与业务逻辑无关的代码,删除没有调用的代码(现在市面上有些这种技术方案,可以检测当前项目中没有被实际调用的代码)。


    3、生成流程图


    先让 GPT 根据代码生成结构化的 Json 数据。



    给你一段 Java 代码,请你使用 spoon 输出结构化的 Json 数据。要求:请你直接输出结构的 json 结果数据,不需要过程代码



    然后,可以让 GPT 根据 Json 数据生成流程图数据,使用流程图工具打开即可。



    给你一段 Spoon 结构化 Java 代码的 Json 数据,整理对应 Java 代码的意思,生成一个流程图数据,流程图使用 PlantUML。现在请输出能直接绘制 PlantUML 图的数据




    三、改进方案


    我们可以从下面几个方面改进这个项目,从而实现真正落地,解决实际公司需求:



    1. 获取代码,修改为从 gitlab 等代码仓库直接拉取,这样使用的时候不需要将工具包导入到具体的 Java 项目中。

    2. 优化生图,提前生成全量图标,通过浏览器的形式进行访问。

    3. 增加图表内容手动校正功能,生成不准确的,支持开发人员手动调整。

    4. 增加检索功能,可以按照自然语言进行检索。

    5. 把项目中的方法和类信息存起来,生成更准确的图标。

    6. 根据完整项目代码,反向生成项目概要图,可能能得到更准确的概要图。

    7. 递归方法流程图,可以使用流程图嵌套,如下进行展示。



    四、总结


    AI 在编程领域,除了大厂都在卷的代码助手,结合自己公司还有很多可探索的地方,比如本文说的可视化 Java 项目,还可以通过分析日志,进行异常、故障的根因分析,做到快速定位问题,帮助快速解决问题,减少影响。


    如果故障根因分析这个工具做出来了,阿里云的 P0 故障,滴滴的 P0 故障,还有很多大中小厂的故障,是不是能更快恢复?减少声誉、金钱损失?


    就说,项目可视化这个需求,据我了解的内部消息,有些互联网中大厂已经在使用这个方式进行落地了。另外,我陪伴群里也有同学接触到了类似不少甲方的类似的强需求,如果想深入这块技术的同学,不管是进互联网大厂还是做自己的副业产品都是不错的方向!


    作者:伍六七AI编程
    来源:juejin.cn/post/7311652298227990563
    收起阅读 »

    简单一招竟把nginx服务器性能提升50倍

    需求背景 接到重点业务需求要分轮次展示数据,预估最高承接 9w 的 QPS,作为后端工程师下意识的就是把接口写好,分级缓存、机器扩容、线程拉满等等一系列连招准备,再因为数据更新频次两只手都数得过来,我们采取了最稳妥的处理方式,直接生成静态文件拿 CDN 抗量 ...
    继续阅读 »

    需求背景


    接到重点业务需求要分轮次展示数据,预估最高承接 9w 的 QPS,作为后端工程师下意识的就是把接口写好,分级缓存、机器扩容、线程拉满等等一系列连招准备,再因为数据更新频次两只手都数得过来,我们采取了最稳妥的处理方式,直接生成静态文件拿 CDN 抗量


    架构流程大致如下所示:



    数据更新后会重新生成新一轮次的文件,刷新 CDN 的时候会触发大量回源请求,应用服务器极端情况得 hold 住这 9w 的 QPS


    第一次压测


    双机房一共 40 台 4C 的机器,25KB 数据文件,5w 的 QPS 直接把 CPU 打到 90%


    这明显不符合业务需求啊,咋办?先无脑加机器试试呗


    就在这时测试同学反馈压测的数据不对,最后一轮文件最大会有 125KB,雪上加霜


    于是乎文件替换,机器数量整体翻一倍扩到 80 台,服务端 CPU 依然是瓶颈,QPS 加不上去了



    到底是哪里在消耗 CPU 资源呢,整体架构已经简单到不能再简单了


    这时候我们注意到为了节省网络带宽 nginx 开启了 gzip 压缩,是不是这小子搞的鬼


    server
    {
    listen 80;

    gzip on;
    gzip_disable "msie6";
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain application/css text/css application/xml text/javascript application/javascript application/x-javascript;

    ......
    }



    第二次压测


    为了验证这个猜想,我们把 nginx 中的 gzip 压缩率从 6 调成 2,以减少 CPU 的计算量



    gzip_comp_level 2;



    这轮压下来 CPU 还是很快被打满,但 QPS 勉强能达到 9w,坐实了确实是 gzip 在耗 CPU



    nginx 作为家喻户晓的 web 服务器,以高性能高并发著称,区区一个静态数据文件就把应用服务器压的这么高,一定是哪里不对


    第三次压测


    明确了 gzip 在耗 CPU 之后我们潜下心来查阅了相关资料,发现了一丝进展


    html/css/js 等静态文件通常包含大量空格、标签等重复字符,重复出现的部分使用「距离加长度」表达可以减少字符数,进而大幅降低带宽,这就是 gzip 无损压缩的基本原理


    作为一种端到端的压缩技术,gzip 约定文件在服务端压缩完成,传输中保持不变,直到抵达客户端。这不妥妥的理论依据嘛~


    nginx 中的 gzip 压缩分为动态压缩和静态压缩两种


    •动态压缩


    服务器给客户端返回响应时,消耗自身的资源进行实时压缩,保证客户端拿到 gzip 格式的文件


    这个模块是默认编译的,详情可以查看 nginx.org/en/docs/htt…


    •静态压缩


    直接将预先压缩过的 .gz 文件返回给客户端,不再实时压缩文件,如果找不到 .gz 文件,会使用对应的原始文件


    这个模块需要单独编译,详情可以查看 nginx.org/en/docs/htt…


    如果开启了 gzip_static always,而且客户端不支持 gzip,还可以在服务端加装 gunzip 来帮助客户端解压,这里我们就不需要了


    查了一下 jdos 自带的 nginx 已经编译了 ngx_http_gzip_static_module,省去了重新编译的麻烦事



    接下来通过 GZIPOutputStream 在本地额外生成一个 .gz 的文件,nginx 配置上静态压缩再来一次



    gzip_static on;




    面对 9w 的QPS,40 台机器只用了 7% 的 CPU 使用率完美扛下


    为了探底继续加压,应用服务器 CPU 增长缓慢,直到网络流出速率被拉到了 89MB/s,担心影响宿主机其他容器停止压力,此时 QPS 已经来到 27w


    qps 5w->27w 提升 5 倍,CPU 90%->7% 降低 10 倍,整体性能翻了 50 倍不止,这回舒服了~


    写在最后


    经过一连串的分析实践,似乎静态压缩存在“压倒性”优势,那什么场景适合动态压缩,什么场景适合静态压缩呢?一番探讨后得出以下结论



    纯静态不会变化的文件适合静态压缩,提前使用gzip压缩好避免CPU和带宽的浪费。动态压缩适合API接口返回给前端数据这种动态的场景,数据会发生变化,这时候就需要nginx根据返回内容动态压缩,以节省服务器带宽



    作为一名后端工程师,nginx 是我们的老相识了,抬头不见低头见。日常工作中配一配转发规则,查一查 header 设置,基本都是把 nginx 作为反向代理使用。这次是直接访问静态资源,调整过程的一系列优化加深了我们对 gzip 的动态压缩和静态压缩的基本认识,这在 NG 老炮儿眼里显得微不足道,但对于我们来说却是一次难得的技能拓展机会


    在之前的职业生涯里,我们一直聚焦于业务架构设计与开发,对性能的优化似乎已经形成思维惯性。面对大数据量长事务请求,减少循环变批量,增大并发,增加缓存,实在不行走异步任务解决,一般瓶颈都出现在 I/O 层面,毕竟磁盘慢嘛,减少与数据库的交互次数往往就有效果,其他大概率不是问题。这回有点儿不一样,CPU 被打起来的原因就是出现了大量数据计算,在高并发请求前,任何一个环节都可能产生性能问题


    作者:京东零售 闫创


    来源:京东云开发者社区 转载请注明来源


    作者:京东云开发者
    来源:juejin.cn/post/7328766815101206547
    收起阅读 »

    多租户架构设计思考

    共享数据库,共享表 描述 所有租户的数据都在同一个数据库表内,以租户字段:tenant_id来区分。 优点 成本低,实现方式简单,适合中小型项目的快速实现。 缺点 数据隔离性差,某一个租户的数据量大的时候,会影响其他租户数据的操作效率。 需要在表上增加租户字...
    继续阅读 »

    共享数据库,共享表


    描述


    所有租户的数据都在同一个数据库表内,以租户字段:tenant_id来区分。


    优点


    成本低,实现方式简单,适合中小型项目的快速实现。


    缺点



    • 数据隔离性差,某一个租户的数据量大的时候,会影响其他租户数据的操作效率。

    • 需要在表上增加租户字段,对系统有一定的侵入性。

    • 数据备份困难,因为所有租户的数据混合在一起,所以针对某个租户数据的备份、恢复会比较麻烦。


    实现方式


    **方式一:**编写Mybatis拦截器,拦截增删改查操作,动态的增加租户条件,如:


    SELECT * FROM sys_user;

    修改成:


    SELECTG * FROM sys_user WHERE tenant_id = 100;

    这种方案并不靠谱,因为动态修改SQL语句不是一个好的处理方式,如果SQL解析没有做好,或者出现复杂SQL,那么很容易产生bug。


    **方式二:**编写Mybatis拦截器,拦截增删改查操作,判断是否有租户条件,如:


    SELECT * FROM sys_user WHERE id=1;

    使用jsqlparser工具解析SQL,判断出该SQL语句没有tenant_id的条件,那么抛出异常,不允许执行。


    这种方案比较稳妥,因为只做判断不做修改。


    查询操作的优先级不高,如果不在乎数据敏感,可以不拦截。


    要注意的是修改操作,稍不注意容易被某一个租户影响其他租户的数据。


    共享数据库,独立一张表


    描述


    所有租户的数据都在同一个数据库中,但是各自有一个独立的表,如:


    # 1号租户的用户表
    sys_user_1

    # 2号租户的用户表
    sys_user_2

    ...

    优点


    成本低,数据隔离性比共享表稍好,并且不用新增租户字段,对系统没有侵入性。


    缺点



    • 数据隔离性虽然比共享表好了些,但是因为仍在同一数据库下,所以某一个租户影响其他租户的数据操作效率问题依然存在。

    • 数据备份困难的问题依然存在。


    实现方式


    **方式一:**编写Mybatis拦截器,拦截增删改查操作,动态的修改表名称,如:


    SELECT * FROM sys_user;

    修改成:


    SELECT * FROM sys_user_1;

    同样的,这种动态修改SQL语句的方式并不推荐,所以我们有另一种方式。


    **方式二:**将表名作为参数传入


    本来在Mapper.xml中,查询语句是这样的:


    SELECT * FROM sys_user WHERE id = #{userId};

    现在改成:


    SELECT * FROM #{tableName} WHERE id = #{userId};

    这样可以避免动态修改SQL语句操作。


    独立数据库


    描述


    每个租户都单独分配一个数据库,数据完全独立,如:


    database_1;
    database_2;
    ...

    优点



    • 数据隔离性最好,不需要添加租户id字段,租户之间不会被彼此影响。

    • 便于数据备份和恢复。

    • 便于扩展。


    缺点



    • 经费成本高,尤其在有多个租户的情况下。

    • 运维成本高。


    结论


    一般来说,当数据量不高的时候,选择共享数据库共享表的方式,表内加个租户id字段做区分,数据量或者用户量多起来,就可以直接升级到独立数据库的方式,因为独立表的方式处理起来是有些麻烦的,倒不如加个字段来的方便。


    作者:失败的面
    来源:juejin.cn/post/7282953307529953291
    收起阅读 »

    一种好用的KV存储封装方案

    一、 概述 众所周知,用kotlin委托属性去封装KV存储库,可以优化数据的访问。 封装方法有多种,各有优劣。 通过反复实践,笔者摸索出一套比较好用的方案,借此文做个简单的分享。 代码已上传Github: github.com/BillyWei01/… 项目...
    继续阅读 »

    一、 概述


    众所周知,用kotlin委托属性去封装KV存储库,可以优化数据的访问。

    封装方法有多种,各有优劣。

    通过反复实践,笔者摸索出一套比较好用的方案,借此文做个简单的分享。


    代码已上传Github: github.com/BillyWei01/…

    项目中是基于SharePreferences封装的,但这套方案也适用于其他类型的KV存储框架。


    二、 封装方法


    此方案封装了两类委托:



    1. 基础类型

      基础类型包括 [boolean, int, float, long, double, String, Set<String>, Object] 等类型。

      其中,Set<String> 本可以通过 Object 类型囊括,

      但因为Set<String>是 SharePreferences 内置支持的类型,这里我们就直接内置支持了。

    2. 扩展key的基础类型

      基础类型的委托,定义属性时需传入常量的key,通过委托所访问到的是key对应的value

      而开发中有时候需要【常量+变量】的key,基础类型的委托无法实现。

      为此,方案中实现了一个 CombineKV 类。

      CombineKV通过组合[key+extKey]实现通过两级key来访问value的效果。

      此外,方案基于CombineKV封装了各种基础类型的委托,用于简化API,以及约束所访问的value的类型。


    2.1 委托实现


    基础类型BasicDelegate.kt

    扩展key的基础类型: ExtDelegate.kt


    这里举例一下基础类型中的Boolean类型的委托实现:


    class BooleanProperty(private val key: String, private val defValue: Boolean) :
    ReadWriteProperty<KVData, Boolean> {
    override fun getValue(thisRef: KVData, property: KProperty<*>): Boolean {
    return thisRef.kv.getBoolean(key, defValue)
    }

    override fun setValue(thisRef: KVData, property: KProperty<*>, value: Boolean) {
    thisRef.kv.putBoolean(key, value)
    }
    }

    class NullableBooleanProperty(private val key: String) :
    ReadWriteProperty<KVData, Boolean?> {
    override fun getValue(thisRef: KVData, property: KProperty<*>): Boolean? {
    return thisRef.kv.getBoolean(key)
    }

    override fun setValue(thisRef: KVData, property: KProperty<*>, value: Boolean?) {
    thisRef.kv.putBoolean(key, value)
    }
    }

    经典的 ReadWriteProperty 实现:

    分别重写 getValue 和 setValue 方法,方法中调用KV存储的读写API。

    由于kotlin区分了可空类型和非空类型,方案中也分别封装了可空和非空两种委托。


    2.2 基类定义


    实现了委托之后,我们将各种委托API封装到一个基类中:KVData


    abstract class KVData {
    // 存储接口
    abstract val kv: KVStore

    // 基础类型
    protected fun boolean(key: String, defValue: Boolean = false) = BooleanProperty(key, defValue)
    protected fun int(key: String, defValue: Int = 0) = IntProperty(key, defValue)
    protected fun float(key: String, defValue: Float = 0f) = FloatProperty(key, defValue)
    protected fun long(key: String, defValue: Long = 0L) = LongProperty(key, defValue)
    protected fun double(key: String, defValue: Double = 0.0) = DoubleProperty(key, defValue)
    protected fun string(key: String, defValue: String = "") = StringProperty(key, defValue)
    protected fun stringSet(key: String, defValue: Set<String> = emptySet()) = StringSetProperty(key, defValue)
    protected fun <T> obj(key: String, encoder: ObjectEncoder<T>, defValue: T) = ObjectProperty(key, encoder, defValue)

    // 可空的基础类型
    protected fun nullableBoolean(key: String) = NullableBooleanProperty(key)
    protected fun nullableInt(key: String) = NullableIntProperty(key)
    protected fun nullableFloat(key: String) = NullableFloatProperty(key)
    protected fun nullableLong(key: String) = NullableLongProperty(key)
    protected fun nullableDouble(key: String) = NullableDoubleProperty(key)
    protected fun nullableString(key: String) = NullableStringProperty(key)
    protected fun nullableStringSet(key: String) = NullableStringSetProperty(key)
    protected fun <T> nullableObj(key: String, encoder: NullableObjectEncoder<T>) = NullableObjectProperty(key, encoder)

    // 扩展key的基础类型
    protected fun extBoolean(key: String, defValue: Boolean = false) = ExtBooleanProperty(key, defValue)
    protected fun extInt(key: String, defValue: Int = 0) = ExtIntProperty(key, defValue)
    protected fun extFloat(key: String, defValue: Float = 0f) = ExtFloatProperty(key, defValue)
    protected fun extLong(key: String, defValue: Long = 0L) = ExtLongProperty(key, defValue)
    protected fun extDouble(key: String, defValue: Double = 0.0) = ExtDoubleProperty(key, defValue)
    protected fun extString(key: String, defValue: String = "") = ExtStringProperty(key, defValue)
    protected fun extStringSet(key: String, defValue: Set<String> = emptySet()) = ExtStringSetProperty(key, defValue)
    protected fun <T> extObj(key: String, encoder: ObjectEncoder<T>, defValue: T) = ExtObjectProperty(key, encoder, defValue)

    // 扩展key的可空的基础类型
    protected fun extNullableBoolean(key: String) = ExtNullableBooleanProperty(key)
    protected fun extNullableInt(key: String) = ExtNullableIntProperty(key)
    protected fun extNullableFloat(key: String) = ExtNullableFloatProperty(key)
    protected fun extNullableLong(key: String) = ExtNullableLongProperty(key)
    protected fun extNullableDouble(key: String) = ExtNullableDoubleProperty(key)
    protected fun extNullableString(key: String) = ExtNullableStringProperty(key)
    protected fun extNullableStringSet(key: String) = ExtNullableStringSetProperty(key)
    protected fun <T> extNullableObj(key: String, encoder: NullableObjectEncoder<T>) = ExtNullableObjectProperty(key, encoder)

    // CombineKV
    protected fun combineKV(key: String) = CombineKVProperty(key)
    }

    使用时,继承KVData,然后实现kv, 返回一个KVStore的实现类即可。


    举例,如果用SharedPreferences实现KVStore,可如下实现:


    class SpKV(name: String): KVStore {
    private val sp: SharedPreferences =
    AppContext.context.getSharedPreferences(name, Context.MODE_PRIVATE)
    private val editor: SharedPreferences.Editor = sp.edit()

    override fun putBoolean(key: String, value: Boolean?) {
    if (value == null) {
    editor.remove(key).apply()
    } else {
    editor.putBoolean(key, value).apply()
    }
    }

    override fun getBoolean(key: String): Boolean? {
    return if (sp.contains(key)) sp.getBoolean(key, false) else null
    }

    // ...... 其他类型
    }


    更多实现可参考: SpKV


    三、 使用方法


    object LocalSetting : KVData("local_setting") {
    override val kv: KVStore by lazy {
    SpKV(name)
    }
    // 是否开启开发者入口
    var enableDeveloper by boolean("enable_developer")

    // 用户ID
    var userId by long("user_id")

    // id -> name 的映射。
    val idToName by extNullableString("id_to_name")

    // 收藏
    val favorites by extStringSet("favorites")

    var gender by obj("gender", Gender.CONVERTER, Gender.UNKNOWN)
    }


    定义委托属性的方法很简单:



    • 和定义变量类似,需要声明变量名类型

    • 和变量声明不同,需要传入key

    • 如果要定义自定义类型,需要传入转换器(实现字符串和对象类型的转换),以及默认值


    基本类型的读写,和变量的读写一样。

    例如:


    fun test1(){
    // 写入
    LocalSetting.userId = 10001L
    LocalSetting.gender = Gender.FEMALE

    // 读取
    val uid = LocalSetting.userId
    val gender = LocalSetting.gender
    }

    读写扩展key的基本类型,则和Map的语法类似:


    fun test2() {
    if (LocalSetting.idToName[1] == null || LocalSetting.idToName[2] == null) {
    Log.d("TAG", "Put values to idToName")
    LocalSetting.idToName[1] = "Jonn"
    LocalSetting.idToName[2] = "Mary"
    } else {
    Log.d("TAG", "There are values in idToName")
    }
    Log.d("TAG", "idToName values: " +
    "1 -> ${LocalSetting.idToName[1]}, " +
    "2 -> ${LocalSetting.idToName[2]}"
    )
    }

    扩展key的基本类型,extKey是Any类型,也就是说,以上代码的[],可以传入任意类型的参数。


    四、数据隔离


    4.1 用户隔离


    不同环境(开发环境/测试环境),不同用户,最好数据实例是分开的,相互不干扰。

    比方说有 uid='001' 和 uid='002' 两个用户的数据,如果需要隔离两者的数据,有多种方法,例如:



    1. 拼接uid到key中。


      如果是在原始的SharePreferences的基础上,是比较好实现的,直接put(key+uid, value)即可;

      但是如果用委托属性定义,可以用上面定义的扩展key的类型。


    2. 拼接uid到文件名中。


      但是不同用户的数据糅合到一个文件中,对性能多少有些影响:



      • 在多用户的情况下,实例的数据膨胀;

      • 每次访问value, 都需要拼接uid到key上。


      因此,可以将不同用户的数据保存到不同的实例中。

      具体的做法,就是拼接uid到路径或者文件名上。



    基于此分析,我们定义两种类型的基类:



    • GlobalKV: 全局数据,切换环境和用户,不影响GlobalKV所访问的数据实例。

    • UserKV: 用户数据,需要同时区分 “服务器环境“ 和 ”用户ID“。


    open class GlobalKV(name: String) : KVData() {
    override val kv: KVStore by lazy {
    SpKV(name)
    }
    }

    abstract class UserKV(
    private val name: String,
    private val userId: Long
    ) : KVData() {
    override val kv: SpKV by lazy {
    // 拼接UID作为文件名
    val fileName = "${name}_${userId}_${AppContext.env.tag}"
    if (AppContext.debug) {
    SpKV(fileName)
    } else {
    // 如果是release包,可以对文件名做个md5,以便匿藏uid等信息
    SpKV(Utils.getMD5(fileName.toByteArray()))
    }
    }
    }

    UserKV实例:


    /**
    * 用户信息
    */

    class UserInfo(uid: Long) : UserKV("user_info", uid) {
    companion object {
    private val map = ArrayMap<Long, UserInfo>()

    // 返回当前用户的实例
    fun get(): UserInfo {
    return get(AppContext.uid)
    }

    // 根据uid返回对应的实例
    @Synchronized
    fun get(uid: Long): UserInfo {
    return map.getOrPut(uid) {
    UserInfo(uid)
    }
    }
    }

    var gender by intEnum("gender", Gender.CONVERTER)
    var isVip by boolean("is_vip")

    // ... 其他变量
    }

    UserKV的实例不能是单例(不同的uid对应不同的实例)。

    因此,可以定义companion对象,用来缓存实例,以及提供获取实例的API。


    保存和读取方法如下:

    先调用get()方法获取,然后其他用法就和前面描述的用法一样了。


    UserInfo.get().gender = Gender.FEMALE

    val gender = UserInfo.get().gender

    4.2 环境隔离


    有一类数据,需要区分环境,但是和用户无关。

    这种情况,可以用UserKV, 然后uid传0(或者其他的uid用不到的数值)。


    /**
    * 远程设置
    */

    object RemoteSetting : UserKV("remote_setting", 0L) {
    // 某项功能的AB测试分组
    val fun1ABTestGr0up by int("fun1_ab_test_group")

    // 服务端下发的配置项
    val setting by combineKV("setting")
    }

    五、小结


    通过属性委托封装KV存储的API,可使原来“类名 + 操作 + key”的方式,变更为“类名 + 属性”的方式,从而简化KV存储的使用。
    另外,这套方案也提到了保存不同用户数据到不同实例的演示。


    方案内容不多,但其中包含一些比较实用的技巧,希望对各位读者有所帮助。


    作者:呼啸长风
    来源:juejin.cn/post/7323449163420303370
    收起阅读 »

    java 实现后缀表达式

    一、概述 后缀表达式(也称为逆波兰表达式)是一种数学表达式的表示方法,其中操作符位于操作数的后面。这种表示法消除了括号,并且在计算机科学和计算中非常有用,因为它更容易计算和解析。 与中缀表达式(通常我们使用的数学表达式,例如"a * (b + c)")不同,后...
    继续阅读 »

    一、概述


    后缀表达式(也称为逆波兰表达式)是一种数学表达式的表示方法,其中操作符位于操作数的后面。这种表示法消除了括号,并且在计算机科学和计算中非常有用,因为它更容易计算和解析。


    与中缀表达式(通常我们使用的数学表达式,例如"a * (b + c)")不同,后缀表达式的运算符放在操作数之后,例如:“a b c + *”。后缀表达式的计算方法是从左到右遍历表达式,遇到操作数时将其压入栈,遇到操作符时从栈中弹出所需数量的操作数进行计算,然后将结果重新压入栈。这个过程一直持续到整个表达式处理完毕,最终栈中只剩下一个结果,即表达式的计算结果。


    后缀表达式具有以下优点:



    1. 不需要括号,因此消除了歧义。

    2. 更容易计算,因为遵循一定的计算顺序。

    3. 适用于计算机的堆栈操作,因此在编译器和计算器中经常使用。


    转换中缀表达式为后缀表达式需要使用算法,通常是栈数据结构。


    二、后缀表达式的运算顺序


    后缀表达式的运算顺序是从左到右遍历表达式,遇到操作数时将其压入栈,遇到操作符时从栈中弹出所需数量的操作数进行计算,然后将计算结果重新压入栈。这个过程一直持续到整个表达式处理完毕,最终栈中只剩下一个结果,即表达式的计算结果。


    后缀表达式的运算顺序是非常直观的,它遵循从左到右的顺序。当计算后缀表达式时,按照以下规则:



    1. 从左到右扫描后缀表达式中的每个元素(操作数或操作符)。

    2. 如果遇到操作数,将其推入栈。

    3. 如果遇到操作符,从栈中弹出所需数量的操作数进行计算,然后将计算结果推回栈中。

    4. 重复这个过程,直到遍历完整个后缀表达式。


    三、常规表达式转化为后缀表达式



    • 创建两个栈,一个用于操作符(操作符栈),另一个用于输出后缀表达式(输出栈)。

    • 从左到右遍历中缀表达式的每个元素。

    • 如果是操作数,将其添加到输出栈。

    • 如果是操作符:

    • 如果操作符栈为空,直接将该操作符推入操作符栈。

      否则,比较该操作符与操作符栈栈顶的操作符的优先级。如果当前操作符的优先级较高,将其推入操作符栈。

      如果当前操作符的优先级较低或相等,从操作符栈中弹出并添加到输出栈,然后重复比较直到可以推入操作符栈。

      如果遇到左括号"(“,直接推入操作符栈。

      如果遇到右括号”)“,将操作符栈中的操作符弹出并添加到输出栈,直到遇到匹配的左括号”("。

      最后,将操作符栈中的剩余操作符全部弹出并添加到输出栈。

      完成遍历后,输出栈中的内容就是中缀表达式转化为后缀表达式的结果。


    四、代码实现


    /**
    * 定义操作符的优先级
    */

    private Map<String, Integer> opList =
    Map.of("(",3,")",3,"*",2,"/",2,"+",1,"-",1);

    public List<String> getPostExp(List<String> source) {

    // 数字栈
    Stack<String> dataStack = new Stack<>();
    // 操作数栈
    Stack<String> opStack = new Stack<>();
    // 操作数集合
    for (int i = 0; i < source.size(); i++) {
    String d = source.get(i).trim();
    // 操作符的操作
    if (opList.containsKey(d)) {
    operHandler(d,opStack,dataStack);
    } else {
    // 操作数直接入栈
    dataStack.push(d);
    }
    }
    // 操作数栈中的数据,到压入到栈中
    while (!opStack.isEmpty()) {
    dataStack.push(opStack.pop());
    }
    List<String> result = new ArrayList<>();
    while (!dataStack.isEmpty()) {
    String pop = dataStack.pop();
    result.add(pop);
    }
    // 对数组进行翻转
    return CollUtil.reverse(result);
    }

    /**
    * 对操作数栈的操作
    * @param d,当前操作符
    * @param opStack 操作数栈
    */

    private void operHandler(String d, Stack<String> opStack,Stack<String> dataStack) {
    // 操作数栈为空
    if (opStack.isEmpty()) {
    opStack.push(d);
    return;
    }
    // 如果遇到左括号"(“,直接推入操作符栈。
    if (d.equals("(")) {
    opStack.push(d);
    return;
    }
    // 如果遇到右括号”)“,将操作符栈中的操作符弹出并添加到输出栈,直到遇到匹配的左括号”("。
    if (d.equals(")")) {
    while (!opStack.isEmpty()) {
    String pop = opStack.pop();
    // 不是左括号
    if (!pop.equals("(")) {
    dataStack.push(pop);
    } else {
    return;
    }
    }
    }
    // 操作数栈不为空
    while (!opStack.isEmpty()) {
    // 获取栈顶元素和优先级
    String peek = opStack.peek();
    Integer v = opList.get(peek);
    // 获取当前元素优先级
    Integer c = opList.get(d);
    // 如果当前操作符的优先级较低或相等,且不为(),从操作符栈中弹出并添加到输出栈,然后重复比较直到可以推入操作符栈
    if (c < v && v != 3) {
    // 出栈
    opStack.pop();
    // 压入结果集栈
    dataStack.push(peek);
    } else {
    // 操作符与操作符栈栈顶的操作符的优先级。如果当前操作符的优先级较高,将其推入操作符栈。
    opStack.push(d);
    break;
    }
    }
    }

    测试代码如下:


    PostfixExpre postfixExpre = new PostfixExpre();

    List<String> postExp = postfixExpre.getPostExp(
    Arrays.asList("9", "+", "(" , "3", "-", "1", ")", "*", "3", "+", "10", "/", "2"));

    System.out.println(postExp);

    输出如下:


    [9, 3, 1, -, 3, *, 10, 2, /, +, +]


    五、求后缀表示值


    使用栈来实现


        /****
    * 计算后缀表达式的值
    * @param source
    * @return
    */

    public double calcPostfixExpe(List<String> source) {

    Stack<String> data = new Stack<>();
    for (int i = 0; i < source.size(); i++) {
    String s = source.get(i);
    // 如果是操作数
    if (opList.containsKey(s)) {
    String d2 = data.pop();
    String d1 = data.pop();
    Double i1 = Double.valueOf(d1);
    Double i2 = Double.valueOf(d2);
    Double result = null;
    switch (s) {
    case "+":
    result = i1 + i2;break;
    case "-":
    result = i1 - i2;break;
    case "*":
    result = i1 * i2;break;
    case "/":
    result = i1 / i2;break;
    }
    data.push(String.valueOf(result));
    } else {
    // 如果是操作数,进栈操作
    data.push(s);
    }
    }
    // 获取结果
    String pop = data.pop();
    return Double.valueOf(pop);
    }

    测试


    PostfixExpre postfixExpre = new PostfixExpre();

    List<String> postExp = postfixExpre.getPostExp(
    Arrays.asList("9", "+", "(" , "3", "-", "1", ")", "*", "3", "+", "10", "/", "2"));

    System.out.println(postExp);

    double v = postfixExpre.calcPostfixExpe(postExp);

    System.out.println(v);

    结果如下:


    [9, 3, 1, -, 3, *, 10, 2, /, +, +]
    20.0

    作者:小希爸爸
    来源:juejin.cn/post/7330583100059762697
    收起阅读 »

    什么是Spring Boot中的@Async

    异步方法 随着硬件和软件的高度发展,现代应用变得更加复杂和要求更高。由于 高需求,工程师总是试图寻找新的方法来提高应用程序性能和响应能力。慢节奏应用程序的一种解决方案是实施异步方法。异步处理是一种执行任务并发运行的进程或函数,无需等待一个任务完成后再开始另一个...
    继续阅读 »

    异步方法


    随着硬件和软件的高度发展,现代应用变得更加复杂和要求更高。由于

    高需求,工程师总是试图寻找新的方法来提高应用程序性能和响应能力。慢节奏应用程序的一种解决方案是实施异步方法。异步处理是一种执行任务并发运行的进程或函数,无需等待一个任务完成后再开始另一个任务。在本文中,我将尝试探索 Spring Boot 中的异步方法和 @Async 注解,试图解释多线程和并发之间的区别,以及何时使用或避免它。


    Spring中的@Async是什么?


    Spring 中的 @Async 注解支持方法调用的异步处理。它指示框架在单独的线程中执行该方法,允许调用者继续执行而无需等待该方法完成。这

    提高了应用程序的整体响应能力和吞吐量。


    要使用@Async,您必须首先通过将@EnableAsync注释添加到配置类来在应用程序中启用异步处理:


    @Configuration
    @EnableAsync
    public class AppConfig {
    }

    接下来,用@Async注解来注解你想要异步执行的方法:



    @Service
    public class AsyncService {
    @Async
    public void asyncMethod() {
    // Perform time-consuming task
    }
    }

    @Async 与多线程和并发有何不同?


    有时,区分多线程和并发与并行执行可能会让人感到困惑,但是,两者都与并行执行相关。他们每个人都有自己的用例和实现:



    • @Async 注解是 Spring 框架特定的抽象,它支持异步执行。它提供了轻松使用异步的能力,在后台处理所有艰苦的工作,例如线程创建、管理和执行。这使用户能够专注于业务逻辑而不是底层细节。

    • 多线程是一个通用概念,通常指操作系统或程序同时管理多个线程的能力。由于 @Async 帮助我们自动完成所有艰苦的工作,在这种情况下,我们可以手动处理所有这些工作并创建一个多线程环境。 Java 具有ThreadExecutorService等必要的类来创建和使用多线程。

    • 并发是一个更广泛的概念,它涵盖多线程和并行执行技术。它是

      系统在一个或多个处理器上同时执行多个任务的能力。


    综上所述,@Async是一种更高层次的抽象,它为开发人员简化了异步处理,而多线程和并发更多的是手动管理并行执行。


    何时使用 @Async 以及何时避免它。


    使用异步方法似乎非常直观,但是,必须考虑到这种方法也有注意事项。


    在以下情况下使用@Async:



    • 您拥有可以并发运行的独立且耗时的任务,而不会影响应用程序的响应能力。

    • 您需要一种简单而干净的方法来启用异步处理,而无需深入研究低级线程管理。


    在以下情况下避免使用 @Async:



    • 您想要异步执行的任务具有复杂的依赖性或需要大量的协调。在这种情况下,您可能需要使用更高级的并发 API,例如CompletableFuture或反应式编程库,例如 Project Reactor。

    • 您必须精确控制线程的管理方式,例如自定义线程池或高级同步机制。在这些情况下,请考虑使用 Java 的ExecutorService或其他并发实用程序。


    在 Spring Boot 应用程序中使用 @Async。


    在此示例中,我们将创建一个简单的 Spring Boot 应用程序来演示 @Async 的使用。

    让我们创建一个简单的订单管理服务。



    1. 创建一个具有最低依赖要求的新 Spring Boot 项目:


      org.springframework.boot:spring-boot-starter

      org.springframework.boot:spring-boot-starter-web

      Web 依赖用于 REST 端点演示目的。 @Async 带有引导启动程序。


    2. 将 @EnableAsync 注释添加到主类或应用程序配置类(如果我们使用它):


    @SpringBootApplication
    @EnableAsync
    public class AsyncDemoApplication {
    public static void main(String[] args) {
    SpringApplication.run(AsyncDemoApplication.class, args);
    }
    }

    @Configuration
    @EnableAsync
    public class ApplicationConfig {}


    1. 对于最佳解决方案,我们可以做的是,创建一个自定义 Executor bean 并根据我们的需要在同一个 Configuration 类中对其进行自定义:


       @Configuration
    @EnableAsync
    public class ApplicationConfig {

    @Bean
    public Executor getAsyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("");
    executor.initialize();
    return executor;
    }
    }

    通过此配置,我们可以控制最大和默认线程池大小。以及其他有用的定制。



    1. 使用 @Async 方法创建 OrderService 类:


    @Service
    public class OrderService {

    @Async
    public void saveOrderDetails(Order order) throws InterruptedException {
    Thread.sleep(2000);
    System.out.println(order.name());
    }

    @Async
    public CompletableFuture<String> saveOrderDetailsFuture(Order order) throws InterruptedException {
    System.out.println("Execute method with return type + " + Thread.currentThread().getName());
    String result = "Hello From CompletableFuture. Order: ".concat(order.name());
    Thread.sleep(5000);
    return CompletableFuture.completedFuture(result);
    }

    @Async
    public CompletableFuture<String> compute(Order order) throws InterruptedException {
    String result = "Hello From CompletableFuture CHAIN. Order: ".concat(order.name());
    Thread.sleep(5000);
    return CompletableFuture.completedFuture(result);
    }
    }

    我们在这里所做的是创建 3 种不同的异步方法。第一个saveOrderDetails服务是一个简单的异步

    服务,它将开始异步计算。如果我们想使用现代异步Java功能,

    例如CompletableFuture,我们可以通过服务来实现saveOrderDetailsFuture。通过这个服务,我们可以调用一个线程来等待@Async的结果。应该注意的是,CompletableFuture.get()在结果可用之前会阻塞。如果我们想在结果可用时执行进一步的异步操作,我们可以使用thenApplythenAccept或 CompletableFuture 提供的其他方法。



    1. 创建一个 REST 控制器来触发异步方法:


    @RestController
    public class AsyncController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
    this.orderService = orderService;
    }

    @PostMapping("/process")
    public ResponseEntity<Void> process(@RequestBody Order order) throws InterruptedException {
    System.out.println("PROCESSING STARTED");
    orderService.saveOrderDetails(order);
    return ResponseEntity.ok(null);
    }

    @PostMapping("/process/future")
    public ResponseEntity<String> processFuture(@RequestBody Order order) throws InterruptedException, ExecutionException {
    System.out.println("PROCESSING STARTED");
    CompletableFuture<String> orderDetailsFuture = orderService.saveOrderDetailsFuture(order);
    return ResponseEntity.ok(orderDetailsFuture.get());
    }

    @PostMapping("/process/future/chain")
    public ResponseEntity<Void> processFutureChain(@RequestBody Order order) throws InterruptedException, ExecutionException {
    System.out.println("PROCESSING STARTED");
    CompletableFuture<String> computeResult = orderService.compute(order);
    computeResult.thenApply(result -> result).thenAccept(System.out::println);
    return ResponseEntity.ok(null);
    }
    }

    现在,当我们访问/process端点时,服务器将立即返回响应,同时

    继续saveOrderDetails()在后台执行。 2秒后,服务完成。第二个端点 -/process/future将使用我们的第二个选项,CompletableFuture在这种情况下,5 秒后,服务将完成,并将结果存储在CompletableFuture我们可以进一步使用future.get()来访问结果。在最后一个端点 - 中/process/future/chain,我们优化并使用了异步计算。控制器使用相同的服务方法CompletableFuture,但不久之后,我们将使用thenApply,thenAccept方法。服务器立即返回响应,我们不需要等待5秒,计算将在后台完成。在这种情况下,最重要的一点是对异步服务的调用,在我们的例子中compute()必须从同一类的外部完成。如果我们在一个方法上使用@Async并在同一个类中调用它,它将不起作用。这是因为Spring使用代理来添加异步行为,并且在内部调用方法会绕过代理。为了使其发挥作用,我们可以:



    • 将 @Async 方法移至单独的服务或组件。

    • 使用 ApplicationContext 获取代理并调用其上的方法。


    总结


    Spring 中的 @Async 注解是在应用程序中启用异步处理的强大工具。通过使用@Async,我们不需要陷入并发管理和多线程的复杂性来增强应用程序的响应能力和性能。但要决定何时使用 @Async 或使用替代并发

    使用程序,了解其局限性和用例非常重要。


    作者:it键盘侠
    来源:juejin.cn/post/7330227149176881161
    收起阅读 »

    新来个架构师,把xxl-job原理讲的炉火纯青~~

    大家好,我是三友~~ 今天来继续探秘系列,扒一扒轻量级的分布式任务调度平台Xxl-Job背后的架构原理 公众号:三友的java日记 核心概念 这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,快速讲一讲Xxl-...
    继续阅读 »

    大家好,我是三友~~


    今天来继续探秘系列,扒一扒轻量级的分布式任务调度平台Xxl-Job背后的架构原理



    公众号:三友的java日记



    核心概念


    这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,快速讲一讲Xxl-Job中的概念和使用


    如果你已经使用过了,可直接跳过本节和下一节,快进到后面原理部分讲解


    1、调度中心


    调度中心是一个单独的Web服务,主要是用来触发定时任务的执行


    它提供了一些页面操作,我们可以很方便地去管理这些定时任务的触发逻辑


    调度中心依赖数据库,所以数据都是存在数据库中的


    调度中心也支持集群模式,但是它们所依赖的数据库必须是同一个


    所以同一个集群中的调度中心实例之间是没有任何通信的,数据都是通过数据库共享的



    2、执行器


    执行器是用来执行具体的任务逻辑的


    执行器你可以理解为就是平时开发的服务,一个服务实例对应一个执行器实例


    每个执行器有自己的名字,为了方便,你可以将执行器的名字设置成服务名


    3、任务


    任务什么意思就不用多说了


    一个执行器中也是可以有多个任务的



    总的来说,调用中心是用来控制定时任务的触发逻辑,而执行器是具体执行任务的,这是一种任务和触发逻辑分离的设计思想,这种方式的好处就是使任务更加灵活,可以随时被调用,还可以被不同的调度规则触发。




    来个Demo


    1、搭建调度中心


    调度中心搭建很简单,先下载源码



    github.com/xuxueli/xxl…



    然后改一下数据库连接信息,执行一下在项目源码中的/doc/db下的sql文件



    启动可以打成一个jar包,或者本地启动就是可以的


    启动完成之后,访问下面这个地址就可以访问到控制台页面了



    http://localhost:8080/xxl-job-admin/toLogin



    用户名密码默认是 admin/123456


    2、执行器和任务添加


    添加一个名为sanyou-xxljob-demo执行器



    任务添加



    执行器选择我们刚刚添加的,指定任务名称为TestJob,corn表达式的意思是每秒执行一次


    创建完之后需要启动一下任务,默认是关闭状态,也就不会执行




    创建执行器和任务其实就是CRUD,并没有复杂的业务逻辑



    按照如上配置的整个Demo的意思就是


    每隔1s,执行一次sanyou-xxljob-demo这个执行器中的TestJob任务


    3、创建执行器和任务


    引入依赖


    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>com.xuxueli</groupId>
            <artifactId>xxl-job-core</artifactId>
            <version>2.4.0</version>
        </dependency>
    </dependencies>

    配置XxlJobSpringExecutor这个Bean


    @Configuration
    public class XxlJobConfiguration {

        @Bean
        public XxlJobSpringExecutor xxlJobExecutor() {
            XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
            //设置调用中心的连接地址
            xxlJobSpringExecutor.setAdminAddresses("http://localhost:8080/xxl-job-admin");
            //设置执行器的名称
            xxlJobSpringExecutor.setAppname("sanyou-xxljob-demo");
            //设置一个端口,后面会讲作用
            xxlJobSpringExecutor.setPort(9999);
            //这个token是保证访问安全的,默认是这个,当然可以自定义,
            // 但需要保证调度中心配置的xxl.job.accessToken属性跟这个token是一样的
            xxlJobSpringExecutor.setAccessToken("default_token");
            //任务执行日志存放的目录
            xxlJobSpringExecutor.setLogPath("./");
            return xxlJobSpringExecutor;
        }

    }

    XxlJobSpringExecutor这个类的作用,后面会着重讲


    通过@XxlJob指定一个名为TestJob的任务,这个任务名需要跟前面页面配置的对应上


    @Component
    public class TestJob {

        private static final Logger logger = LoggerFactory.getLogger(TestJob.class);

        @XxlJob("TestJob")
        public void testJob() {
            logger.info("TestJob任务执行了。。。");
        }

    }

    所以如果顺利的话,每隔1s钟就会打印一句TestJob任务执行了。。。


    启动项目,注意修改一下端口,因为调用中心默认也是8080,本地起会端口冲突


    最终执行结果如下,符合预期



    讲完概念和使用部分,接下来就来好好讲一讲Xxl-Job核心的实现原理


    从执行器启动说起


    前面Demo中使用到了一个很重要的一个类



    XxlJobSpringExecutor



    这个类就是整个执行器启动的入口



    这个类实现了SmartInitializingSingleton接口


    所以经过Bean的生命周期,一定会调用afterSingletonsInstantiated这个方法的实现


    这个方法干了很多初始化的事,这里我挑三个重要的讲,其余的等到具体的功能的时候再提


    1、初始化JobHandler


    JobHandler是个什么?


    所谓的JobHandler其实就是一个定时任务的封装



    一个定时任务会对应一个JobHandler对象


    当执行器执行任务的时候,就会调用JobHandler的execute方法


    JobHandler有三种实现:



    • MethodJobHandler

    • GlueJobHandler

    • ScriptJobHandler


    MethodJobHandler是通过反射来调用方法执行任务



    所以MethodJobHandler的任务的实现就是一个方法,刚好我们demo中的例子任务其实就是一个方法


    所以Demo中的任务最终被封装成一个MethodJobHandler


    GlueJobHandler比较有意思,它支持动态修改任务执行的代码


    当你在创建任务的时候,需要指定运行模式为GLUE(Java)



    之后需要在操作按钮点击GLUE IDE编写Java代码



    代码必须得实现IJobHandler接口,之后任务执行的时候就会执行execute方法的实现


    如果你需要修改任务的逻辑,只需要重新编辑即可,不需要重启服务


    ScriptJobHandler,通过名字也可以看出,是专门处理一些脚本的


    运行模式除了BEANGLUE(Java)之外,其余都是脚本模式


    而本节的主旨,所谓的初始化JobHandler就是指,执行器启动的时候会去Spring容器中找到加了@XxlJob注解的Bean


    解析注解,然后封装成一个MethodJobHandler对象,最终存到XxlJobSpringExecutor成员变量的一个本地的Map缓存中



    缓存key就是任务的名字



    至于GlueJobHandler和ScriptJobHandler都是任务触发时才会创建


    除了上面这几种,你也自己实现JobHandler,手动注册到JobHandler的缓存中,也是可以通过调度中心触发的


    2、创建一个Http服务器


    除了初始化JobHandler之外,执行器还会创建一个Http服务器


    这个服务器端口号就是通过XxlJobSpringExecutor配置的端口,demo中就是设置的是9999,底层是基于Netty实现的



    这个Http服务端会接收来自调度中心的请求


    当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理



    这个类非常重要,所有调度中心的请求都是这里处理的


    ExecutorBizImpl实现了ExecutorBiz接口


    当你翻源码的时候会发现,ExecutorBiz还有一个ExecutorBizClient实现



    ExecutorBizClient的实现就是发送http请求,所以这个实现类是在调度中心使用的,用来访问执行器提供的http接口



    3、注册到调度中心


    当执行器启动的时候,会启动一个注册线程,这个线程会往调度中心注册当前执行器的信息,包括两部分数据



    • 执行器的名字,也就是设置的appname

    • 执行器所在机器的ip和端口,这样调度中心就可以访问到这个执行器提供的Http接口


    前面提到每个服务实例都会对应一个执行器实例,所以调用中心会保存每个执行器实例的地址




    这里你可以把调度中心的功能类比成注册中心



    任务触发原理


    弄明白执行器启动时干了哪些事,接下来讲一讲Xxl-Job最最核心的功能,那就是任务触发的原理


    任务触发原理我会分下面5个小点来讲解



    • 任务如何触发?

    • 快慢线程池的异步触发任务优化

    • 如何选择执行器实例?

    • 执行器如何去执行任务?

    • 任务执行结果的回调


    1、任务如何触发?


    调度中心在启动的时候,会开启一个线程,这个线程的作用就是来计算任务触发时机,这里我把这个线程称为调度线程


    这个调度线程会去查询xxl_job_info这张表


    这张表存了任务的一些基本信息和任务下一次执行的时间


    调度线程会去查询下一次执行的时间 <= 当前时间 + 5s的任务


    这个5s是XxlJob写死的,被称为预读时间,提前读出来,保证任务能准时触发


    举个例子,假设当前时间是2023-11-29 08:00:10,这里的查询就会查出下一次任务执行时间在2023-11-29 08:00:15之前执行的任务



    查询到任务之后,调度线程会去将这些任务根据执行时间划分为三个部分:



    • 当前时间已经超过任务下一次执行时间5s以上,也就是需要在2023-11-29 08:00:05(不包括05s)之前的执行的任务

    • 当前时间已经超过任务下一次执行时间,但是但不足5s,也就是在2023-11-29 08:00:052023-11-29 08:00:10(不包括10s)之间执行的任务

    • 还未到触发时间,但是一定是5s内就会触发执行的



    对于第一部分的已经超过5s以上时间的任务,会根据任务配置的调度过期策略来选择要不要执行



    调度过期策略就两种,就是字面意思



    • 直接忽略这个已经过期的任务

    • 立马执行一次这个过期的任务


    对于第二部分的超时时间在5s以内的任务,就直接立马执行一次,之后如果判断任务下一次执行时间就在5s内,会直接放到一个时间轮里面,等待下一次触发执行


    对于第三部分任务,由于还没到执行时间,所以不会立马执行,也是直接放到时间轮里面,等待触发执行


    当这批任务处理完成之后,不论是前面是什么情况,调度线程都会去重新计算每个任务的下一次触发时间,然后更新xxl_job_info这张表的下一次执行时间


    到此,一次调度的计算就算完成了


    之后调度线程还会继续重复上面的步骤,查任务,调度任务,更新任务下次执行时间,一直死循环下去,这就实现了任务到了执行时间就会触发的功能


    这里在任务触发的时候还有一个很有意思的细节


    由于调度中心可以是集群的形式,每个调度中心实例都有调度线程,那么如何保证任务在同一时间只会被其中的一个调度中心触发一次?


    我猜你第一时间肯定想到分布式锁,但是怎么加呢?


    XxlJob实现就比较有意思了,它是基于八股文中常说的通过数据库来实现的分布式锁的


    在调度之前,调度线程会尝试执行下面这句sql



    就是这个sql



    select * from xxl_job_lock where lock_name = 'schedule_lock' for update



    一旦执行成功,说明当前调度中心成功抢到了锁,接下来就可以执行调度任务了


    当调度任务执行完之后再去关闭连接,从而释放锁


    由于每次执行之前都需要去获取锁,这样就保证在调度中心集群中,同时只有一个调度中心执行调度任务


    最后画一张图来总结一下这一小节



    2、快慢线程池的异步触发任务优化


    当任务达到了触发条件,并不是由调度线程直接去触发执行器的任务执行


    调度线程会将这个触发的任务交给线程池去执行


    所以上图中的最后一部分触发任务执行其实是线程池异步去执行的


    那么,为什么要使用线程池异步呢?


    主要是因为触发任务,需要通过Http接口调用具体的执行器实例去触发任务



    这一过程必然会耗费时间,如果调度线程去做,就会耽误调度的效率


    所以就通过异步线程去做,调度线程只负责判断任务是否需要执行


    并且,Xxl-Job为了进一步优化任务的触发,将这个触发任务执行的线程池划分成快线程池慢线程池两个线程池



    在调用执行器的Http接口触发任务执行的时候,Xxl-Job会去记录每个任务的触发所耗费的时间


    注意并不是任务执行时间,只是整个Http请求耗时时间,这是因为执行器执行任务是异步执行的,所以整个时间不包括任务执行时间,这个后面会详细说


    当任务一次触发的时间超过500ms,那么这个任务的慢次数就会加1


    如果这个任务一分钟内触发的慢次数超过10次,接下来就会将触发任务交给慢线程池去执行


    所以快慢线程池就是避免那种频繁触发并且每次触发时间还很长的任务阻塞其它任务的触发的情况发生


    3、如何选择执行器实例?


    上一节说到,当任务需要触发的时候,调度中心会向执行器发送Http请求,执行器去执行具体的任务


    那么问题来了



    由于一个执行器会有很多实例,那么应该向哪个实例请求?



    这其实就跟任务配置时设置的路由策略有关了



    从图上可以看出xxljob支持多种路由策略


    除了分片广播,其余的具体的算法实现都是通过ExecutorRouter的实现类来实现的



    这里简单讲一讲各种算法的原理,有兴趣的小伙伴可以去看看内部的实现细节


    第一个、最后一个、轮询、随机都很简单,没什么好说的


    一致性Hash讲起来比较复杂,你可以先看看这篇文章,再去查看Xxl-Job的代码实现



    zhuanlan.zhihu.com/p/470368641



    最不经常使用(LFU:Least Frequently Used):Xxl-Job内部会有一个缓存,统计每个任务每个地址的使用次数,每次都选择使用次数最少的地址,这个缓存每隔24小时重置一次


    最近最久未使用(LRU:Least Recently Used):将地址存到LinkedHashMap中,它利用LinkedHashMap可以根据元素访问(get/put)顺序来给元素排序的特性,快速找到最近最久未使用(未访问)的节点


    故障转移:调度中心都会去请求每个执行器,只要能接收到响应,说明执行器正常,那么任务就会交给这个执行器去执行


    忙碌转移:调度中心也会去请求每个执行器,判断执行器是不是正在执行当前需要执行的任务(任务执行时间过长,导致上一次任务还没执行完,下一次又触发了),如果在执行,说明忙碌,不能用,否则就可以用


    分片广播:XxlJob给每个执行器分配一个编号,从0开始递增,然后向所有执行器触发任务,告诉每个执行器自己的编号和总共执行器的数据


    我们可以通过XxlJobHelper#getShardIndex获取到编号,XxlJobHelper#getShardTotal获取到执行器的总数据量


    分片广播就是将任务量分散到各个执行器,每个执行器只执行一部分任务,加快任务的处理


    举个例子,比如你现在需要处理30w条数据,有3个执行器,此时使用分片广播,那么此时可将任务分成3分,每份10w条数据,执行器根据自己的编号选择对应的那份10w数据处理



    当选择好了具体的执行器实例之后,调用中心就会携带一些触发的参数,发送Http请求,触发任务


    4、执行器如何去执行任务?


    相信你一定记得我前面在说执行器启动是会创建一个Http服务器的时候提到这么一句



    当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理



    所以前面提到的故障转移和忙碌转移请求执行器进行判断,最终执行器也是交给ExecutorBizImpl处理的


    执行器处理触发请求是这个ExecutorBizImpl的run方法实现的



    当执行器接收到请求,在正常情况下,执行器会去为这个任务创建一个单独的线程,这个线程被称为JobThread



    每个任务在触发的时候都有单独的线程去执行,保证不同的任务执行互不影响



    之后任务并不是直接交给线程处理的,而是直接放到一个内存队列中,线程直接从队列中获取任务



    这里我相信你一定有个疑惑



    为什么不直接处理,而是交给队列,从队列中获取任务呢?



    那就得讲讲不正常的情况了


    如果调度中心选择的执行器实例正在处理定时任务,那么此时该怎么处理呢?**


    这时就跟阻塞处理策略有关了



    阻塞处理策略总共有三种:



    • 单机串行

    • 丢弃后续调度

    • 覆盖之前调度


    单机串行的实现就是将任务放到队列中,由于队列是先进先出的,所以就实现串行,这也是为什么放在队列的原因


    丢弃调度的实现就是执行器什么事都不用干就可以了,自然而然任务就丢了


    覆盖之前调度的实现就很暴力了,他是直接重新创建一个JobThread来执行任务,并且尝试打断之前的正在处理任务的JobThread,丢弃之前队列中的任务



    打断是通过Thread#interrupt方法实现的,所以正在处理的任务还是有可能继续运行,并不是说一打断正在运行的任务就终止了



    这里需要注意的一点就是,阻塞处理策略是对于单个执行器上的任务来生效的,不同执行器实例上的同一个任务是互不影响的


    比如说,有一个任务有两个执行器A和B,路由策略是轮询


    任务第一次触发的时候选择了执行器实例A,由于任务执行时间长,任务第二次触发的时候,执行器的路由到了B,此时A的任务还在执行,但是B感知不到A的任务在执行,所以此时B就直接执行了任务


    所以此时你配置的什么阻塞处理策略就没什么用了


    如果业务中需要保证定时任务同一时间只有一个能运行,需要把任务路由到同一个执行器上,比如路由策略就选择第一个


    5、任务执行结果的回调


    当任务处理完成之后,执行器会将任务执行的结果发送给调度中心



    如上图所示,这整个过程也是异步化的



    • JobThread会将任务执行的结果发送到一个内存队列中

    • 执行器启动的时候会开启一个处发送任务执行结果的线程:TriggerCallbackThread

    • 这个线程会不停地从队列中获取所有的执行结果,将执行结果批量发送给调度中心

    • 调用中心接收到请求时,会根据执行的结果修改这次任务的执行状态和进行一些后续的事,比如失败了是否需要重试,是否有子任务需要触发等等


    到此,一次任务的就算真正处理完成了


    最后


    最后我从官网捞了一张Xxl-Job架构图



    奈何作者不更新呐,导致这个图稍微有点老了,有点跟现有的架构对不上


    比如说图中的自研RPC(xxl-rpc)部分已经替换成了Http协议,这主要是拥抱生态,方便跨语言接入


    但是不要紧,大体还是符合现在的整个的架构


    从架构图中也可以看出来,本文除了日志部分的内容没有提到,其它的整个核心逻辑基本上都讲到了


    而日志部分其实是个辅助的作用,让你更方便查看任务的运行情况,对任务的触发逻辑是没有影响的,所以就没讲了


    所以从本文的讲解再到官方架构图,你会发现整个Xxl-Job不论是使用还是实现都是比较简单的,非常的轻量级


    说点什么


    好了,到这又又成功讲完了一款框架或者说是中间件的核心架构原理,不知道你有没有什么一点收获


    如果你觉得有点收获,欢迎点赞、在看、收藏、转发分享给其他需要的人


    你的支持就是我更新文章最大的动力,非常地感谢!


    其实这篇文章我在十一月上旬的时候我就打算写了


    但是由于十一月上旬之后我遇到一系列烦心事,导致我实在是没有精力去写


    现在到月底了,虽然烦心事只增不少,但是我还是想了想,觉得不能再拖了,最后也是连续肝了几个晚上,才算真正完成


    所以如果你发现文章有什么不足和问题,也欢迎批评指正


    好了,本文就讲到这里了,让我们下期再见,拜拜!


    作者:zzyang90
    来源:juejin.cn/post/7329860521241640971
    收起阅读 »

    为什么不推荐用 UUID 作为 Mysql 的主键

    学习改变命运,技术铸就辉煌。 大家好,我是銘,全栈开发程序员。 UUID 是什么 我们先来了解一下 UUID 是什么?UUID 是指Universally Unique Identifier,翻译为中文是通用唯一识别码,UUID 的目的是让分布式系统中的所有...
    继续阅读 »

    学习改变命运,技术铸就辉煌。



    大家好,我是銘,全栈开发程序员。


    UUID 是什么


    我们先来了解一下 UUID 是什么?UUID 是指Universally Unique Identifier,翻译为中文是通用唯一识别码,UUID 的目的是让分布式系统中的所有元素都能有唯一的识别信息。如此一来,每个人都可以创建不与其它人冲突的 UUID,就不需考虑数据库创建时的名称重复问题。


    UUID 的十六个八位字节被表示为 32个十六进制数字,以连字号分隔的五组来显示,形式为 8-4-4-4-12,总共有 36个字符(即三十二个英数字母和四个连字号)。例如:


    123e4567-e89b-12d3-a456-426655440000
    xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx

    能否用 UUID 做主键


    先说答案 , 能,但是性能会比使用自增主键差一些,那原因是什么,我们具体分析:


    我们平时建表的时候,一般都像下面这样,不会去使用 UUID,使用AUTO INCREMENT直接把主键 id 设置成自增,每次 +1


    CREATE TABLE `user`(
    `id` int NOT NULL AUTO INCREMENT COMMENT '主键',
    `name` char(10NOT NULL DEFAULT '' COMMENT '名字',
     PRIMARY KEY (`id`)
     )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    那为什么把主键设置成自增呢, 我们在数据库保存数据的时候,就类似与下面的表格一样,这每一行数据,都是**保存在一个 16K 大小的页里 **。


    idnameage
    1张三11
    2李四22
    3王五33

    每次都去遍历所有的行性能会不好,于是为了加速搜索,我们可以根据主键 id,从小到大排列这些行数据,将这些数据页用双向链表的形式组织起来,再将这些页里的部分信息提取出来放到一个新的 16kb 的数据页里,再加入层级的概念。于是,一个个数据页就被组织起来了,成为了一棵 B+ 树索引。


    当我们在建表 sql 里面声明 AUTO INCREMENT 的时候,myqsl 的 innodb 引擎,就会为主键 id 生成一个主键索引,里面就是通过 B+ 树的形式来维护这套索引。


    那么现在,我们需要关注两个点,



    1. 数据页大小是固定的 16k

    2. 数据页内,以及数据页之间,数据主键 id 是从小到大排序的


    所以,由于数据页大小固定了 16k ,当我们需要插入一条数据的时候,数据页就会慢慢的被放满,当超过 16k 的时候,这个数据页就可能会进行分裂。


    针对 B+ 树的叶子节点,如果主键是自增的,那么它产生的 id 每次都比前次要大,所以每次都会将数据家在 B+ 树的尾部,B+ 树的叶子节点本质是双向链表,查找它的首部和尾部,时间复杂度 O(1),如果此时最末尾的数据也满了,那创建个新的页就好。


    如果bb,上次 id=12111111,这次 id=343435455,那么为了让新加入数据后 B+ 树的叶子节点海涅那个保持有序,那么就需要旺叶子节点的中间找,查找的时间复杂度是 O(lgn),如果这个页满了,那就需要进行页分裂,并且页分裂的操作是需要加悲观锁的。


    所以,我们一般都建议把主键设置成自增,这样可以提高效率,提高性能


    那什么情况下不设置主键自增


    mysql分库分表下的id


    在分库分表的情况下,插入的 id 都是专门的 id 服务生成的,如果要严格按照自增的话,那么一般就会通过 redis 来生成,按批次去获得,比如一次性获取几百个,用完了再去获取,但是如果 redis 服务挂了,功能就完全没法用了,那有么有不依赖与第三方组件的方法呢?


    雪花算法


    使用时间戳+机器码+流水号,一个字段实现了时间顺序、机器编码、创建时间。去中心化,方便排序,随便多表多库复制,并可抽取出生成时间,雪花ID主要是用在数据库集群上,去中心化,ID不会冲突又能相对排序。


    总结


    一般情况下,我们不推荐使用 UUID 来作为数据库的主键,只有分库分表的时候,才建议使用 UUID 来作为主键。


    作者:銘聊技术
    来源:juejin.cn/post/7328366295091200038
    收起阅读 »

    为什么要用雪花ID替代数据库自增ID?

    今天咱们来看一道数据库中比较经典的面试问题:为什么要使用雪花 ID 替代数据库自增 ID?同时这道题也出现在了浩鲸科技的 Java 面试中,下面我们一起来看吧。 浩鲸科技的面试题如下:   1.什么是雪花 ID? 雪花 ID(Snowflake ID...
    继续阅读 »

    今天咱们来看一道数据库中比较经典的面试问题:为什么要使用雪花 ID 替代数据库自增 ID?同时这道题也出现在了浩鲸科技的 Java 面试中,下面我们一起来看吧。


    浩鲸科技的面试题如下:
    image.png 


    1.什么是雪花 ID?


    雪花 ID(Snowflake ID)是一个用于分布式系统中生成唯一 ID 的算法,由 Twitter 公司提出。它的设计目标是在分布式环境下高效地生成全局唯一的 ID,具有一定的有序性。


    雪花 ID 的结构如下所示:
    image.png
    这四部分代表的含义



    1. 符号位:最高位是符号位,始终为 0,1 表示负数,0 表示正数,ID 都是正整数,所以固定为 0。

    2. 时间戳部分:由 41 位组成,精确到毫秒级。可以使用该 41 位表示的时间戳来表示的时间可以使用 69 年。

    3. 节点 ID 部分:由 10 位组成,用于表示机器节点的唯一标识符。在同一毫秒内,不同的节点生成的 ID 会有所不同。

    4. 序列号部分:由 12 位组成,用于标识同一毫秒内生成的不同 ID 序列。在同一毫秒内,可以生成 4096 个不同的 ID。


    2.Java 版雪花算法实现


    接下来,我们来实现一个 Java 版的雪花算法:


    public class SnowflakeIdGenerator {

    // 定义雪花 ID 的各部分位数
    private static final long TIMESTAMP_BITS = 41L;
    private static final long NODE_ID_BITS = 10L;
    private static final long SEQUENCE_BITS = 12L;

    // 定义起始时间戳(可根据实际情况调整)
    private static final long EPOCH = 1609459200000L;

    // 定义最大取值范围
    private static final long MAX_NODE_ID = (1L << NODE_ID_BITS) - 1;
    private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;

    // 定义偏移量
    private static final long TIMESTAMP_SHIFT = NODE_ID_BITS + SEQUENCE_BITS;
    private static final long NODE_ID_SHIFT = SEQUENCE_BITS;

    private final long nodeId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(long nodeId) {
    if (nodeId < 0 || nodeId > MAX_NODE_ID) {
    throw new IllegalArgumentException("Invalid node ID");
    }
    this.nodeId = nodeId;
    }

    public synchronized long generateId() {
    long currentTimestamp = timestamp();
    if (currentTimestamp < lastTimestamp) {
    throw new IllegalStateException("Clock moved backwards");
    }
    if (currentTimestamp == lastTimestamp) {
    sequence = (sequence + 1) & MAX_SEQUENCE;
    if (sequence == 0) {
    currentTimestamp = untilNextMillis(lastTimestamp);
    }
    } else {
    sequence = 0L;
    }
    lastTimestamp = currentTimestamp;
    return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT) |
    (nodeId << NODE_ID_SHIFT) |
    sequence;
    }

    private long timestamp() {
    return System.currentTimeMillis();
    }

    private long untilNextMillis(long lastTimestamp) {
    long currentTimestamp = timestamp();
    while (currentTimestamp <= lastTimestamp) {
    currentTimestamp = timestamp();
    }
    return currentTimestamp;
    }
    }

    调用代码如下:


    public class Main {
    public static void main(String[] args) {
    // 创建一个雪花 ID 生成器实例,传入节点 ID
    SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(1);
    // 生成 ID
    long id = idGenerator.generateId();
    System.out.println(id);
    }
    }

    其中,nodeId 表示当前节点的唯一标识,可以根据实际情况进行设置。generateId 方法用于生成雪花 ID,采用同步方式确保线程安全。具体的生成逻辑遵循雪花 ID 的位运算规则,结合当前时间戳、节点 ID 和序列号生成唯一的 ID。



    需要注意的是,示例中的时间戳获取方法使用了 System.currentTimeMillis(),根据实际需要可以替换为其他更精确的时间戳获取方式。同时,需要确保节点 ID 的唯一性,避免不同节点生成的 ID 重复。



    3.雪花算法问题


    虽然雪花算法是一种被广泛采用的分布式唯一 ID 生成算法,但它也存在以下几个问题:



    1. 时间回拨问题:雪花算法生成的 ID 依赖于系统的时间戳,要求系统的时钟必须是单调递增的。如果系统的时钟发生回拨,可能导致生成的 ID 重复。时间回拨是指系统的时钟在某个时间点之后突然往回走(人为设置),即出现了时间上的逆流情况。

    2. 时钟回拨带来的可用性和性能问题:由于时间依赖性,当系统时钟发生回拨时,雪花算法需要进行额外的处理,如等待系统时钟追上上一次生成 ID 的时间戳或抛出异常。这种处理会对算法的可用性和性能产生一定影响。

    3. 节点 ID 依赖问题:雪花算法需要为每个节点分配唯一的节点 ID 来保证生成的 ID 的全局唯一性。节点 ID 的分配需要有一定的管理和调度,特别是在动态扩容或缩容时,节点 ID 的管理可能较为复杂。


    4.如何解决时间回拨问题?


    百度 UidGenerator 框架中解决了时间回拨的问题,并且解决方案比较经典,所以咱们这里就来给大家分享一下百度 UidGenerator 是怎么解决时间回拨问题的?



    UidGenerator 介绍:UidGenerator 是百度开源的一个分布式唯一 ID 生成器,它是基于 Snowflake 算法的改进版本。与传统的 Snowflake 算法相比,UidGenerator 在高并发场景下具有更好的性能和可用性。它的实现源码在:github.com/baidu/uid-g…



    UidGenerator 是这样解决时间回拨问题的:UidGenerator 的每个实例中,都维护一个本地时钟缓存,用于记录当前时间戳。这个本地时钟会定期与系统时钟进行同步,如果检测到系统时钟往前走了(出现了时钟回拨),则将本地时钟调整为系统时钟。


    4.为什么要使用雪花 ID 替代数据库自增 ID?


    数据库自增 ID 只适用于单机环境,但如果是分布式环境,是将数据库进行分库、分表或数据库分片等操作时,那么数据库自增 ID 就有问题了。


    例如,数据库分片之后,会在同一张业务表的分片数据库中产生相同 ID(数据库自增 ID 是由每个数据库单独记录和增加的),这样就会导致,同一个业务表的竟然有相同的 ID,而且相同 ID 背后存储的数据又完全不同,这样业务查询的时候就出问题了。


    所以为了解决这个问题,就必须使用分布式中能保证唯一性的雪花 ID 来替代数据库的自增 ID。


    5.扩展:使用 UUID 替代雪花 ID 行不行?


    如果单从唯一性来考虑的话,那么 UUID 和雪花 ID 的效果是一致的,二者都能保证分布式系统下的数据唯一性,但是即使这样,也不建议使用 UUID 替代雪花 ID,因为这样做的问题有以下两个:



    1. 可读性问题:UUID 内容很长,但没有业务含义,就是一堆看不懂的“字母”。

    2. 性能问题:UUID 是字符串类型,而字符串类型在数据库的查询中效率很低。


    所以,基于以上两个原因,不建议使用 UUID 来替代雪花 ID。


    小结


    数据库自增 ID 只适用于单机数据库环境,而对于分库、分表、数据分片来说,自增 ID 不具备唯一性,所以要要使用雪花 ID 来替代数据库自增 ID。但雪花算法依然存在一些问题,例如时间回拨问题、节点过度依赖问题等,所以此时,可以使用雪花算法的改进框架,如百度的 UidGenerator 来作为数据库的 ID 生成方案会比较好。


    作者:Java中文社群
    来源:juejin.cn/post/7307066138487521289
    收起阅读 »

    好坑啊,调用了同事写的基础代码,bug藏得还挺深!!

    起因 事情的起因是我调用了同事的一个函数,这个函数返回了一个map[string]string结构体的变量optionMap(请忽略为什么要返回map结构体,后面有机会再讲),这个函数主要是查DB取获取当前系统的space_id和pkey,返回的内容基本上如下...
    继续阅读 »

    起因


    事情的起因是我调用了同事的一个函数,这个函数返回了一个map[string]string结构体的变量optionMap(请忽略为什么要返回map结构体,后面有机会再讲),这个函数主要是查DB取获取当前系统的space_id和pkey,返回的内容基本上如下


     // 返回
    optionMap = map[string]string{
    "space_id":"xxx",
    "pkey": "xxxx",
    }

    然后我修改了这个变量,添加了



    optionMap["is_base"] = 1

    然后我就return出我当前的函数了,然后在同一个请求内,但是当我再一次请求同事的函数时,返回给我的却是


     optionMap = map[string]string{
    "space_id":"xxx",
    "pkey": "xxxx",
    "is_base": 1,
    }

    what! 怎么后面再次请求同事的的函数总是会多一个参数呢!!!!


    经过


    看了同事写的函数我才发现,原来他在内部使用了gin上下文去做了一个缓存,大概的代码意思减少重复space基础信息的查询,存入上下文中做缓存,提高代码效率,这里我写了一个示例大家可以看下


    // 同事的代码
    func BadReturnMap(ctx *gin.Context, key string) map[string]interface{} {
    m := make(map[string]interface{})
    // 查询缓存
    value, ok := ctx.Get(key)
    if ok {
    bm, ok := value.(map[string]interface{})
    if ok {
    return bm
    }
    }
    // io查询后存入变量
    m["a"] = 1
    // 保存缓存
    fmt.Println("set cache: ")
    fmt.Println(m) // map[a:1]
    ctx.Set(key, m)
    return m
    }

    // 我的使用
    func TestBadReturnMap() {
    fmt.Println("bad return map start")
    ctx := &gin.Context{}
    key := "cached:map_key"
    mapOpt := BadReturnMap(ctx, key)
    fmt.Printf("%p\n", mapOpt) // 0xc0003a6750 指向地址
    mapOpt["b"] = 1
    value, ok := ctx.Get(key)
    fmt.Printf("%p\n", mapOpt) // 0xc0003a6750 指向地址
    if ok {
    fmt.Println("get cache: ")
    fmt.Println(value.(map[string]interface{})) // map[a:1 def:1]
    } else {
    fmt.Println("unknown")
    }
    fmt.Println("bad return map end")
    }

    打印结果是


    bad return map start
    set cache:
    map[a:1]
    0xc0003a6750
    0xc0003a6750
    get cache:
    map[a:1 b:1]
    bad return map end

    解释


    在Go语言中,map是引用类型,当将一个map赋值给另一个变量时,实际上是将它们指向同一个底层的map对象。因此,当你修改其中一个变量的map时,另一个变量也会受到影响。


    当你将函数内m变量赋值给外部函数内的变量时,它们实际上指向同一个map对象。所以当你在外部函数内修改mapOpt的值时,原始的缓存也会被修改。


    如图所示


    image.png


    如何修改


    当然修改方式有很多种,我这里列举了一种就是序列化存储到缓存然后反序列化取,如果你有更好的方式可下方留言



    func ReturnMap(ctx *gin.Context, key string) map[string]interface{} {
    m := make(map[string]interface{})
    value, ok := ctx.Get(key)
    if ok {
    bytes := value.([]byte)
    err := json.Unmarshal(bytes, &m)
    if err != nil {
    panic(err)
    }
    return m
    }
    // io查询后存入变量
    m["a"] = 1
    jsonBytes, err := json.Marshal(m)
    if err != nil {
    panic(err)
    }
    fmt.Println("set cache: ")
    fmt.Println(m)
    ctx.Set(key, jsonBytes)

    return m
    }

    func TestReturnMap() {
    fmt.Println("return map start")
    ctx := &gin.Context{}
    key := "cached:map_key"
    mapOpt := ReturnMap(ctx, key)
    fmt.Printf("%p\n", mapOpt)

    mapOpt["b"] = 1
    fmt.Printf("%p\n", mapOpt)

    value, ok := ctx.Get(key)
    if ok {
    m := make(map[string]interface{})
    bytes := value.([]byte)
    err := json.Unmarshal(bytes, &m)
    if err != nil {
    panic(err)
    }
    fmt.Println("get cache: ")
    fmt.Println(m)
    } else {
    fmt.Println("unknown")
    }
    fmt.Println("return map end")
    }


    打印的结果为:


    return map start
    set cache:
    map[a:1]
    0xc0003a6870
    0xc0003a6870
    get cache:
    map[a:1]
    return map end

    知识点


    作者:沙蒿同学
    来源:juejin.cn/post/7330869056411058239
    收起阅读 »

    大公司如何做 APP:背后的开发流程和技术

    我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用...
    继续阅读 »

    我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用户量级够大,公司才愿意在技术上投入更多的人力资源。因此,在大公司里做技术,对个人的眼界、技术细节和深度的提升都有帮助。


    我记得之前我曾跟同事调侃说,有一天我离职了,我可以说我毕业了,因为我这几年学到了很多。现在我想借这个机会总结下这些年在公司里经历的让我印象深刻的技术。


    1、研发流程


    首先在产品的研发流程上,我把过去公司的研发模式分成两种。


    第一种是按需求排期的。在评审阶段一次性评审很多需求,和开发沟通后可能删掉优先级较低的需求,剩下的需求先开发,再测试,最后上线。上线的时间根据开发和测试最终完成的时间确定。


    第二种是双周迭代模式,属于敏捷开发的一种。这种开发机制里,两周一个版本,时间是固定的。开发、测试和产品不断往时间周期里插入需求。如下图,第一周和第三周的时间是存在重叠的。具体每个阶段留多少时间,可以根据自身的情况决定。如果需求比较大,则可以跨迭代,但发布的时间窗口基本是固定的。


    截屏2023-12-30 13.00.33.png


    有意思的是,第二种开发机制一直是我之前的一家公司里负责人羡慕的“跑火车”模式。深度参与过两种开发模式之后,我说下我的看法。


    首先,第一种开发模式适合排期时间比较长的需求。但是这种方式时间利用率相对较低。比如,在测试阶段,开发一般是没什么事情做的(有的会在这个时间阶段布置支线需求)。这种开发流程也有其好处,即沟通和协调成本相对较低。


    注意!在这里,我们比较时间利用率的时候是默认两种模式的每日工作时间是相等的且在法律允许范围内。毕竟,不论哪一种研发流程,强制加班之后,时间利用率都“高”(至少老板这么觉得)。


    第二种开发方式的好处:



    1. 响应速度快。可以快速发现问题并修复,适合快速试错。

    2. 时间利用率高。相比于按需求排期的方式,不存在开发和测试的间隙期。


    但这种开发方式也有缺点:



    1. 员工压力大,容易造成人员流失。开发和测试时间穿插,开发需要保证开发的质量,否则容易影响整个迭代内开发的进度。

    2. 沟通成本高。排期阶段出现人力冲突需要协调。开发过程中出现问题也需要及时、有效的沟通。因此,在这种开发模式里还有一个角色叫项目经理,负责在中间协调,而第一种开发模式里项目经理的存在感很低。

    3. 这种开发模式中,产品要不断想需求,很容易导致开发的需求本身价值并不大。


    做了这么多年开发,让人很难拒绝一个事实是,绝大多数互联网公司的壁垒既不是技术,也不是产品,而是“快速迭代,快速试错”。从这个角度讲,双周迭代开发机制更适应互联网公司的要求。就像我们调侃公司是给电脑配个人,这种开发模式里就是给“研发流水线”配个人,从产品、到开发、到测试,所有人都像是流水线上的一员。


    2、一个需求的闭环


    以上是需求的研发流程。如果把一个需求从产品提出、到上线、到线上数据回收……整个生命周期列出来,将如下图所示,


    需求闭环.drawio.png


    这里我整合了几个公司的研发过程。我用颜色分成了几个大的流程。相信每个公司的研发流程里或多或少都会包含其中的几个。在这个闭环里,我说一下我印象比较深刻的几个。


    2.1 产品流程


    大公司做产品一个显著的特点是数据驱动,一切都拿数据说话。一个需求的提出只是一个假设,开发上线之后效果评估依赖于数据。数据来源主要有埋点上报和舆情监控。


    1. 数据埋点


    埋点数据不仅用于产品需求的验证,也用于推荐算法的训练。因此,大公司对数据埋点的重视可以说是深入骨髓的。埋点数据也经常被纳入到绩效考核里。


    开发埋点大致要经过如下流程,



    • 1). 产品提出需要埋的点。埋点的类型主要包括曝光和点击等,此外还附带一些上报的参数,统计的维度包括用户 uv 和次数 pv.

    • 2). 数据设计埋点。数据拿到产品要埋的点之后,设计埋点,并在埋点平台录入。

    • 3). 端上开发埋点。端上包括移动客户端和 Web,当然埋点框架也要支持 RN 和 H5.

    • 4). 端上验证埋点。端上埋点完成之后需要测试,上报埋点,然后再在平台做埋点校验。

    • 5). 产品提取埋点数据。

    • 6). 异常埋点数据修复。


    由此可见,埋点及其校验对开发来说也是需要花费精力的一环。它不仅需要多个角色参与,还需要一个大数据平台,一个录入、校验和数据提取平台,以及端上的上报框架,可以说成本并不低。


    2. 舆情监控


    老实说,初次接触舆情监控的时候,它还是给了我一点小震撼的。没想到大公司已经把舆情监控做到了软件身上。


    舆情监控就是对网络上关于该 APP 的舆情的监控,数据来源不仅包括应用内、外用户提交的反馈,还包括主流社交平台上关于该软件的消息。所有数据在整合到舆情平台之后会经过大数据分析和分类,然后进行监控。舆情监控工具可以做到对产品的负面信息预警,帮助产品经理优化产品,是产品研发流程中重要的一环。


    3. AB 实验


    很多同学可能对 AB 实验都不陌生。AB 实验就相当于同时提出多套方案,然后左右手博弈,从中择优录用。AB 实验的一个槽点是,它使得你代码中同时存在多份作用相同的代码,像狗皮膏药一样,也不能删除,非常别扭,最后导致的结果是代码堆积如山。


    4. 路由体系建设


    路由即组件化开发中的页面路由。但是在有些应用里,会通过动态下发路由协议支持运营场景。这在偏运营的应用里比较常见,比如页面的推荐流。一个推荐流里下发的模块可能打开不同的页面,此时,只需要为每个页面配置一个路由路径,然后推荐流里根据需要下发即可。所以,路由体系也需要 Android 和 iOS 双端统一,同时还要兼容 H5 和 RN.


    mdn-url-all.png


    在路由协议的定义上,我们可以参考 URL 的格式,定义自己的协议、域名、路径以及参数。以 Android 端为例,可以在一个方法里根据路由的协议、域名对原生、RN 和 H5 等进行统一分发。


    2.2 开发流程


    在开发侧的流程里,我印象深的有以下几个。


    1. 重视技术方案和文档


    我记得之前在一家公司里只文档平台就换了几个,足见对文档的重视。产品侧当然更重文档,而对研发侧,文档主要有如下几类:1). 周会文档;2).流程和规范;3).技术方案;4).复盘资料等。


    对技术方案,现在即便我自己做技术也保留了写大需求技术方案先行的习惯。提前写技术方案有几个好处:



    • 1). 便于事后回忆:当我们对代码模糊的时候,可以通过技术方案快速回忆。

    • 2). 便于风险预知:技术方案也有助于提前预知开发过程中的风险点。前面我们说敏捷开发提前发现风险很重要,而做技术方案就可以做到这点。

    • 3). 便于全面思考:技术方案能帮助我们更全面地思考技术问题。一上来就写代码很容易陷入“只见树木,不见森林”的困境。


    2. Mock 开发


    Mock 开发也就是基于 Mock 的数据进行开发和测试。在这里它不局限于个人层面(很多人可能有自己 Mock 数据开发的习惯),而是在公司层面将其作为一种开发模式,以实现前后端分离。典型的场景是客户端先上线预埋,而后端开发可能滞后一段时间。为了支持 Mock 开发模式,公司需要专门的平台,提供以接口为维度的 Mock 工具。当客户端切换到 Mock 模式之后,上传到网络请求在后端的网关直接走 Mock 服务器,拉取 Mock 数据而不是真实数据。


    这种开发模式显然也是为了适应敏捷开发模式而提出的。它可以避免前后端依赖,减轻人力资源协调的压力。这种开发方式也有其缺点:



    • 1). 数据结构定义之后无法修改。客户端上线之后后端就无法再修改数据结构。因此,即便后端不开发,也需要先投入人力进行方案设计,定义数据结构,并拉客户端进行评审。

    • 2). 缺少真实数据的验证。在传统的开发模式中,测试要经过测试和 UAT 两个环境,而 UAT 本身已经比较接近线上环境,而使用 Mock 开发就完全做不到这么严谨。当我们使用 Mock 数据测试时,如果我们自己的 Mock 的数据本身失真比较严重,那么在意识上你也不会在意数据的合理性,因此容易忽视一些潜在的问题。


    3. 灰度和热修复


    灰度的机制是,在用户群体中选择部分用户进行应用更新提示的推送。这要求应用本身支持自动更新,同时需要对推送的达到率、用户的更新率进行统计。需要前后端一套机制配合。灰度有助于提前发现应用中存在的问题,这对超大型应用非常有帮助,毕竟,现在上架之后发现问题再修复的成本非常高。


    但如果上架之后确实出现了问题就需要走热修复流程。热修复的难点在于热修复包的下发,同时还需要审核流程,因此需要搭建一个平台。这里涉及的细节比较多,后面有时间再梳理吧。


    4. 配置下发


    配置下发就是通过平台录入配置,推送,然后在客户端读取配置信息。这也是应用非常灵活的一个功能,可以用来下发比如固定的图片、文案等。我之前做个人开发的时候也在服务器上做了配置下发的功能,主要用来绕过某些应用商店的审核,但是在数据结构的抽象上做得比较随意。这里梳理下配置下发的细节。



    • 首先,下发的配置是区分平台特征的。这包括,应用的目标版本(一个范围)、目标平台(Android、iOS、Web、H5 或者 RN)。

    • 其次,为了适应组件化开发,也为了更好地分组管理,下发的配置命名时采用 模块#配置名称 的形式。

    • 最后,下发的数据结构支持,整型、布尔类型、浮点数、字符串和 Json.


    我自己在做配置下发的时候还遇到一个比较棘手的问题——多语言适配。国内公司的产品一般只支持中文,这方面就省事得多。


    5. 复盘文化


    对于敏捷开发,复盘是不可或缺的一环。有助于及时发现问题,纠正和解决问题。复盘的时间可以是定期的,在一个大需求上线之后,或者出现线上问题之后。


    3、技术特点


    3.1 组件化开发的痛点


    在大型应用开发过程中,组件化开发的意义不仅局限于代码结构层面。组件化的作用体现在以下几个层面:



    • 1). 团队配合的利器。想想几十个人往同一份代码仓库里提交代码的场景。组件化可以避免无意义的代码冲突。

    • 2). 提高编译效率。对于大型应用,全源码编译一次的时间可能要几十分钟。将组件打包成 aar 之后可以减少需要编译的代码的数量,提升编译效率。

    • 3). 适应组织架构。将代码细分为各个组件,每个小团队只维护自己的组件,更方便代码权限划分。


    那么,在实际开发过程中组件化开发会存在哪些问题呢?


    1. 组件拆分不合理


    这在从单体开发过渡到组件化开发的应用比较常见,即组件化拆分之后仍然存在某些模块彼此共用,导致提交代码的时候仍然会出现冲突问题。冲突包含两个层面的含义,一是代码文件的 Git 冲突,二是在打包合入过程中发布的 aar 版本冲突。比较常见的是,a 同学合入了代码到主干之后,b 同学没有合并主干到自己的分支就打包,导致发布的 aar 没有包含最新的代码。这涉及打包的问题,是另一个痛点问题,后面再总结。


    单就拆分问题来看,避免上述冲突的一个解决办法是在拆分组件过程中尽可能解耦。根据我之前的观察,存在冲突的组件主要是数据结构和 SPI 接口。这是我之前公司没做好的地方——数据结构仓库和 SPI 接口是共用的。对于它们的组件化拆分,我待过的另一家公司做得更好。他们是如下拆分的,这里以 A 和 B 来命名两个业务模块。那么,在拆分的时候做如下处理,


    模块:A-api
    模块:A
    模块:B-api
    模块:B

    即每个业务模块拆分成 api 和实现两部分。api 模块里包含需要共享的数据结构和 SPI 接口,实现模块里是接口的具体实现。当模块 A 需要和模块 B 进行交互的时候,只需要依赖 B 的 api 模块。可以参考开源项目:arch-android.


    2. 打包合入的痛点


    上面我们提到了一种冲突的情况。在我之前的公司里,每个组件有明确的负责人,在每个迭代开发的时候,组件负责人负责拉最新 release 分支。其他同学在该分支的开发需要经过负责人同意再合入到该分支。那么在最终打包的过程中,只需要保证这个分支的 aar 包含了全部最新的代码即可。也就是说,这种打包方式只关心每个 aar 的版本,而不关心实际的代码。因为它最终打包是基于 aar 而不是全源码编译。


    这种打包方式存在最新的分支代码没有被打包的风险。一种可行的规避方法是,在平台通过 Git tag 和 commit 判断该分支是否已经包含最新代码。此外,还可能存在某个模块修改了 SPI 接口,而另一个模块没有更新,导致运行时异常的风险。


    另一个公司是基于全源码编译的。不过,全源码编译只在最终打包阶段或者某个固定的时间点进行,而不是每次合入都全源码编译(一次耗时太久)。同时,虽然每个模块有明确的负责人,但是打包的 aar 不是基于当前 release 分支,而是自己的开发分支。这是为了保障当前 release 分支始终是可用的。合并代码到 release 分支的同时需要更新 aar 的版本。但它也存在问题,如果合并到 release 而没有打包 aar,那么可能导致 release 分支无法使用。如果打包了 aar 但是此时其他同学也打包了 aar,则可能导致本次打包的 aar 落后,需要重新打包。因此,这种合入方式也是苦不堪言。


    有一种方法可以避免上述问题,即将打包和合入事件设计成一个消息队列。每次合入之前自动化执行上述操作,那么自然就可以保证每次操作的原子性(因为本身就是单线程的)。


    对比两种打包和合入流程,显然第二种方式更靠谱。不过,它需要设计一个流程。这需要花费一点功夫。


    3. 自动化切源码


    我在之前的一家公司开发时,在开发过程中需要引用另一个模块的修改时,需要对另一个模块打 SNAPSHOT 包。这可行,但有些麻烦。之前我也尝试过手动修改 settings.gradle 文件进行源码依赖开发。不过,太麻烦了。


    后来在另一个公司里看到一个方案,即动态切换到源码开发。可以将某个依赖替换为源码而只需要修改脚本即可。这个实践很棒,我已经把它应用到独立开发中。之前已经梳理过《组件化开发必备:Gradle 依赖切换源码的实践》.


    3.2 大前端化开发


    1. React Native


    如今的就业环境,哪个 Android 开发不是同时会五六门手艺。跨平台开发几乎是不可避免的。


    之前的公司为什么选择 React Native 而不是 Flutter 等新锐跨平台技术呢?我当时还刻意问了这个问题。主要原因:



    • 1). 首先是 React Native 相对更加成熟,毕竟我看了下 Github 第一个版本发布已经是 9 年前的事情了,并且至今依旧非常活跃。

    • 2). React Native 最近更新了 JavaScript 引擎,页面启动时间、包大小和内存占用性能都有显著提升。参考这篇文章《干货 | 加载速度提升15%,携程对RN新一代JS引擎Hermes的调研》.

    • 3). 从团队人才配置上,对 React Native 熟悉的更多。


    React Native 开发是另一个领域的东西,不在本文讨论范围内。每个公司选择 React Native 可能有它的目的。比如,我之前的一家公司存粹是为了提效,即一次开发双端运行。而另一家公司,则是为了兼顾提效和动态化。如果只为提效,那么本地编译和打包 js bundle 就可以满足需求。若要追求动态化,就需要搭建一个 RN 包下发平台。实际上,在这个公司开发 RN 的整个流程,除了编码环节,从代码 clone 到最终发布都是在平台上执行的。平台搭建涉及的细节比较多,以后用到再总结。对于端侧,RN 的动态化依赖本地路由以及 RN 容器。


    2. BFF + DSL


    DSL 是一种 UI 动态下发的方案。相比于 React Native,DSL 下发的维度更细,是控件级别的(而 RN 是页面级别的)。简单的理解是,客户端和后端约定 UI 格式,然后按照预定的格式下发的数据。客户端获取到数据之后渲染。DSL 不适合需要复杂动画的场景。若确实要复杂动画,则需要自定义控件。


    工作流程如下图中左侧部分所示,右侧部分是每个角色的责任。


    DSL workflow.drawio.png


    客户端将当前页面和位置信息传给 DSL 服务器。服务器根据上传的信息和位置信息找到业务接口,调用业务接口拉取数据。获取到数据后根据开发过程中配置的脚本对数据进行处理。数据处理完成之后再交给 DSL 服务器渲染。渲染完成之后将数据下发给客户端。客户端再根据下发的 UI 信息进行渲染。其中接口数据的处理是通过 BFF 实现的,由客户端通过编写 Groovy 脚本实现数据结构的转换。


    这种工作流程中,大部分逻辑在客户端这边,需要预埋点位信息。预埋之后可以根据需求进行下发。这种开发的一个痛点在于调试成本高。因为 DSL 服务器是一个黑盒调用。中间需要配置的信息过多,搭建 UI 和编写脚本的平台分散,出现问题不易排查。


    总结


    所谓他山之石,可以攻玉。在这篇文章中,我只是选取了几个自己印象深刻的技术点,零零碎碎地写了很多,比较散。对于有这方面需求的人,会有借鉴意义。


    作者:开发者如是说
    来源:juejin.cn/post/7326268908984434697
    收起阅读 »

    功能问题:如何限制同一账号只能在一处登录?

    大家好,我是大澈! 本文约1200+字,整篇阅读大约需要2分钟。 感谢关注微信公众号:“程序员大澈”,免费领取"面试礼包"一份,然后免费加入问答群,从此让解决问题的你不再孤单! 1. 需求分析 前阵子,和问答群里一个前端朋友,随便唠了唠。期间他问了我一个问题,...
    继续阅读 »

    大家好,我是大澈!


    本文约1200+字,整篇阅读大约需要2分钟。


    感谢关注微信公众号:“程序员大澈”,免费领取"面试礼包"一份,然后免费加入问答群,从此让解决问题的你不再孤单!


    1. 需求分析


    前阵子,和问答群里一个前端朋友,随便唠了唠。期间他问了我一个问题,让我印象深刻。


    他问的是,限制同一账号只能在一处设备上登录,是如何实现的?并且,他还把这个功能称为“单点登录”。


    我说这不叫“单点登录”,这是“单设备登录”。


    于是,当时对此概念区分不清的他,和我在语言上开始了深度纠缠。


    所以在后面我就想,这个功能问题有必要整理一下,分享给现在还不清楚两者概念的朋友们。


    图片



    2. 功能实现


    先聊聊“单点登录”和“单设备登录”区别,再说说实现“单设备登录”的步骤。


    2.1 单点登录和单设备登录的区别


    “单点登录”和“单设备登录”是两个完全不同的概念。


    单设备登录指:在某个给定的时间,同一用户只能在一台设备上进行登录,如果在其他设备上尝试登录,先前的会话将被中断或注销。


    单点登录(简称SSO)指:允许用户使用一组凭据(如用户名和密码)登录到一个系统,然后可以在多个相关系统中,无需重新登录即可访问受保护的资源。


    关于“单点登录”的实现,这里简单说一下。一般有两种方式:若后端处理,部署一个认证中心,这是标准做法;若前端处理,可以用LocalStorage做跨域缓存。


    2.2 单设备登录的实现


    要实现单设备登录,一般来说,有两种方式:使用数据库记录登录状态 和 使用令牌验证机制 。


    使用令牌验证机制 的实现步骤如下:


    • 用户登录时生成token,将账号作为key,token作为value,并设置过期时间存入redis中。


    • 当用户访问应用时,在拦截器中解析token,获取账号,然后用账号去redis中获取相应的value。


    • 如果获取到的value的token与当前用户携带的token一致,则允许访问;如果不一致,则提示前端重复登录,让前端清除token,并跳转到登录页面。


    • 当用户在另一台设备登录时,其token也会存入redis中,这样就刷新了token的值和redis的过期时间。


    图片


    使用数据库记录登录状态 的实现步骤如下:


    • 在用户登录时,记录用户的账号信息、登录设备的唯一标识符(如设备ID或IP地址)以及登录时间等信息到数据库中的一个登录表。


    • 每次用户的登录请求都会查询数据库中的登录表,检查是否存在该用户的登录记录。如果存在记录,则比对登录设备的标识符和当前设备的标识符是否相同。


    • 如果当前设备与登录设备不匹配,拒绝登录并提示用户在其他设备上已登录。若匹配,则更新登录时间。


    • 当用户主动退出登录或超过一定时间没有操作时,清除该用户的登录记录。




    作者:程序员大澈
    来源:juejin.cn/post/7320166206215340072
    收起阅读 »

    Java项目要不要部署在Docker里?

    部署Java项目有很多种方式,传统的方式是直接在物理机或虚拟机上部署应用,但为什么现在容器化部署变得越来越流行, 个人觉得原因有以下几个: 1、 环境一致性:使用Docker可以确保开发、测试和生产环境的一致性,避免出现“在我机器上能跑”的问题。 2、 快速部...
    继续阅读 »

    部署Java项目有很多种方式,传统的方式是直接在物理机或虚拟机上部署应用,但为什么现在容器化部署变得越来越流行,
    个人觉得原因有以下几个:


    1、 环境一致性:使用Docker可以确保开发、测试和生产环境的一致性,避免出现“在我机器上能跑”的问题。

    2、 快速部署:Docker镜像一旦构建完成,可以快速部署到任何支持Docker的宿主机上。

    3、 易于扩展:结合编排工具如 Kubernetes,可以轻松管理服务的伸缩和负载均衡。

    4、 资源隔离:容器化可以提供更好的资源使用隔离和限制,提高系统的稳定性。

    5、 更轻便地微服务化:容器很适合微服务架构,每个服务可以单独打包、部署和扩展。


    至于是否要在Docker里部署,这取决于项目和团队的具体需求。


    如果你的团队追求快速迭代、想要环境一致性,或者计划实现微服务架构,那么使用Docker是一个很好的选择。


    但如果项目比较小,或者团队对容器技术不熟,想使用容器化部署应用,可能会增加学习和维护的成本,那就需要权衡利弊了。


     


    如果你决定使用Docker来部署Java项目,大概的步骤是这样的:


    1、 编写Dockerfile:这是一个文本文件,包含了从基础镜像获取、复制应用文件、设置环境变量到运行应用的所有命令。

    2、 构建镜像:使用docker build命令根据Dockerfile构建成一个可运行的镜像。

    3、 运行容器:使用docker run命令从镜像启动一个或多个容器实例。

    4、 (可选)使用Docker Compose或Kubernetes等工具部署和管理容器。


    部署在Docker里的Java项目,通常都会需要一个精心编写的Dockerfile和一些配置管理,确保应用可以无障碍地在容器中运行。




    下面简单演示一个如何使用Docker来部署一个简单的Spring Boot Java项目。


     


    首先,我们需要安装Docker,你可以从Docker官网下载合适的版本安装,安装完后可以通过运行docker --version来检查是否安装成功。


    Docker 安装步骤在在这里就不详细说明了,可以参考这篇文章:CentOS Docker 安装


    项目部署步骤:


    步骤1:编写Dockerfile


    Dockerfile是一个文本文件,它包含了一系列的指令和参数,用于定义如何构建你的Docker镜像。
    以下是一个典型的Dockerfile示例,用于部署一个Spring Boot应用:


    # 使用官方提供的Java运行环境作为基础镜像,根据自己的需求,选择合适的JDK版本,这里以 1.8 为例
    FROM openjdk:8-jdk-alpine

    # 配置环境变量
    ENV APP_FILE myapp.jar
    ENV APP_HOME /usr/app

    # 在容器内创建一个目录作为工作目录
    WORKDIR $APP_HOME

    # 将构建好的jar包复制到容器内的工作目录下
    COPY target/*.jar $APP_FILE

    # 暴露容器内部的端口给外部使用
    EXPOSE 8080

    # 启动Java应用
    ENTRYPOINT ["java","-jar","${APP_FILE}"]

    注释解释:



    • FROM openjdk:8-jdk-alpine:这告诉Docker使用一个轻量级的Java 8 JDK版本作为基础镜像。

    • ENV:设置环境变量,这里设置了应用的jar包名称和存放路径。

    • WORKDIR:设定工作目录,之后的COPY等命令都会在这个目录下执行。

    • COPY:将本地的jar文件复制到镜像中。

    • EXPOSE:将容器的8080端口暴露出去,以便外部可以访问容器内的应用。

    • ENTRYPOINT:容器启动时执行的命令,这里是运行Java应用的命令。


    步骤2:构建镜像


    在Dockerfile所在的目录运行下面的命令来构建你的镜像:


    docker build -t my-java-app .

    这里的-t标记用于给新创建的镜像设置一个名称,.是上下文路径,指向Dockerfile所在的当前目录。


    步骤3:运行容器


    构建好镜像后,你可以使用下面的命令来运行容器:


    docker run -d -p 8080:8080 --name my-running-app my-java-app

    这里的-d标记意味着在后台运行容器,-p标记用于将容器的8080端口映射到宿主机的8080端口,--name用于给容器设置名字。


    到这里,如果一切顺利,你的Spring Boot应用就会在Docker容器中启动,
    并且宿主机的8080端口会转发到容器内部的同一端口上,你可以通过访问http://xxxx:8080来查看应用是否在运行。


    步骤4:使用Docker Compose或Kubernetes等工具部署和管理容器


    接下来我们来讲讲如何使用Docker Compose来管理和部署容器。
    Docker Compose是一个用于定义和运行多容器Docker应用的工具。使用Compose,你可以通过一个YAML文件来配置你的应用的服务,然后只需要一个简单的命令即可创建和启动所有的服务。


    就拿上面的例子来说,我们来创建一个docker-compose.yml 文件来运行Spring Boot应用。


    先确保你已经安装了Docker Compose,然后创建以下内容的docker-compose.yml文件:


    version: '3'
    services:
    my-java-app:
    build: .
    ports:
    - "8080:8080"
    environment:
    SPRING_PROFILES_ACTIVE: "prod"
    volumes:
    - "app-logs:/var/log/my-java-app"

    volumes:
    app-logs:

    注释解释:



    • version:指定了我们使用的Compose文件版本。

    • services:定义了我们需要运行的服务。

      • my-java-app:这是我们服务的名称。

      • build: .:告诉Compose在当前目录下查找Dockerfile来构建镜像。

      • ports:将容器端口映射到主机端口。

      • environment:设置环境变量,这里我们假设应用使用Spring Profiles,定义了prod作为激活的配置文件。

      • volumes:定义了数据卷,这里我们将宿主机的一个卷挂载到容器中,用于存储日志等数据。




    创建好docker-compose.yml文件后,只需要运行以下命令即可:


    docker-compose up -d

    这条命令会根据你的docker-compose.yml文件启动所有定义的服务。 -d 参数表明要在后台运行服务。


    如果你需要停止并移除所有服务,可以使用:


    docker-compose down

    使用Docker Compose的好处是,你可以在一个文件中定义整个应用的服务以及它们之间的依赖,然后一键启动或停止所有服务,非常适合本地开发和测试。


    至于Kubernetes,它是一个开源的容器编排系统,用于自动部署、扩展和管理容器化应用。


     


    Kubernetes的学习曲线相对陡峭,适合用于更复杂的生产环境。如果你想要进一步了解Kubernetes:


    推荐几个 Kubernetes 学习的文章



    总结


    总的来说,容器化是Java项目部署的一种高效、现代化方式,适合于追求快速迭代和微服务架构的团队。
    对于不熟悉容器技术的团队或者个人开发者而言,需要考虑学习和维护的成本,合适自己的才是最好的,也不必追求别人用什么你就用什么,得不偿失。


    作者:小郑说编程i
    来源:juejin.cn/post/7330102782538055689
    收起阅读 »

    提升网站性能的秘诀:为什么Nginx是高效服务器的代名词?

    在这个信息爆炸的时代,每当你在浏览器中输入一个网址,背后都有一个强大的服务器在默默地工作。而在这些服务器中,有一个名字你可能听说过无数次——Nginx。今天,就让我们一起探索这个神奇的工具。一、Nginx是什么Nginx(发音为“enginex”)是一个开源的...
    继续阅读 »

    在这个信息爆炸的时代,每当你在浏览器中输入一个网址,背后都有一个强大的服务器在默默地工作。而在这些服务器中,有一个名字你可能听说过无数次——Nginx。今天,就让我们一起探索这个神奇的工具。

    一、Nginx是什么

    Nginx(发音为“enginex”)是一个开源的高性能HTTP和反向代理服务器。它由伊戈尔·赛索耶夫(IgorSysoev)于2002年创建,自那时起,Nginx因其稳定性、丰富的功能集、简单的配置文件以及低资源消耗而受到广大开发者和企业的喜爱。

    Description

    Nginx是一款轻量级的Web服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器,在BSD-like协议下发行。

    其特点是占有内存少,并发能力强,事实上nginx的并发能力在同类型的网页服务器中表现较好,中国大陆使用nginx网站用户有:百度、京东、新浪、网易、腾讯、淘宝等。

    二、Nginx的反向代理与正向代理

    Description

    正向代理:

    我们平时需要访问国外的浏览器是不是很慢,比如我们要看推特,看GitHub等等。我们直接用国内的服务器无法访问国外的服务器,或者是访问很慢。

    所以我们需要在本地搭建一个服务器来帮助我们去访问。那这种就是正向代理。(浏览器中配置代理服务器)

    反向代理:

    那什么是反向代理呢。比如:我们访问淘宝的时候,淘宝内部肯定不是只有一台服务器,它的内部有很多台服务器,那我们进行访问的时候,因为服务器中间session不共享,那我们是不是在服务器之间访问需要频繁登录。

    这个时候淘宝搭建一个过渡服务器,对我们是没有任何影响的,我们是登录一次,但是访问所有,这种情况就是反向代理。

    对我们来说,客户端对代理是无感知的,客户端不需要任何配置就可以访问,我们只需要把请求发送给反向代理服务器,由反向代理服务器去选择目标服务器获取数据后,再返回给客户端。

    此时反向代理服务器和目标服务器对外就是一个服务器,暴露的是代理服务器地址,隐藏了真实服务器的地址。(在服务器中配置代理服务器)

    三、Nginx的负载均衡

    什么是负载均衡?

    负载均衡建立在现有网络结构之上,它提供了一种廉价有效透明的方法扩展网络设备和服务器的带宽、增加吞吐量、加强网络数据处理能力、提高网络的灵活性和可用性。

    负载均衡(LoadBalance)其意思就是分摊到多个操作单元上进行执行,例如Web服务器、FTP服务器、企业关键应用服务器和其它关键任务服务器等,从而共同完成工作任务。

    Description

    负载均衡的主要目的是确保网络流量被平均分发到多个节点,从而提高整体系统的响应速度和可用性。它对于处理高并发请求非常重要,因为它可以防止任何单一节点过载,导致服务中断或性能下降。

    Nginx给出来三种关于负载均衡的方式:

    轮询法(默认方法):

    每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器down掉,能自动剔除。

    适合服务器配置相当,无状态且短平快的服务使用。也适用于图片服务器集群和纯静态页面服务器集群。

    weight权重模式(加权轮询):

    指定轮询几率,weight和访问比率成正比,用于后端服务器性能不均的情况。

    这种方式比较灵活,当后端服务器性能存在差异的时候,通过配置权重,可以让服务器的性能得到充分发挥,有效利用资源。weight和访问比率成正比,用于后端服务器性能不均的情况。权重越高,在被访问的概率越大。

    ip_hash:

    上述方式存在一个问题就是说,在负载均衡系统中,假如用户在某台服务器上登录了,那么该用户第二次请求的时候,因为我们是负载均衡系统,每次请求都会重新定位到服务器集群中的某一个。

    那么已经登录某一个服务器的用户再重新定位到另一个服务器,其登录信息将会丢失,这样显然是不妥的。

    你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!

    点这里即可查看!

    我们可以采用ip_hash指令解决这个问题,如果客户已经访问了某个服务器,当用户再次访问时,会将该请求通过哈希算法,自动定位到该服务器。每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session的问题。

    四、Nginx的动静分离

    为了加快网站的解析速度,可以把动态页面和静态页面由不同的服务器来解析,加快解析速度。降低原来单个服务器的压力。

    Description

    Nginx的静态处理能力很强,但是动态处理能力不足,因此,在企业中常用动静分离技术。

    动静分离技术其实是采用代理的方式,在server{}段中加入带正则匹配的location来指定匹配项针对PHP的动静分离:

    静态页面交给Nginx处理,动态页面交给PHP-FPM模块或Apache处理。在Nginx的配置中,是通过location配置段配合正则匹配实现静态与动态页面的不同处理方式。

    五、Nginx特点

    那么,Nginx到底有哪些特点让它如此受欢迎呢?让我们一起来探索。

    1、高性能与低消耗

    Nginx采用了事件驱动的异步非阻塞模型,这意味着它在处理大量并发连接时,可以有效地使用系统资源。与传统的服务器相比,Nginx可以在较低的硬件配置下提供更高的性能。这对于成本敏感的企业来说,无疑是一个巨大的优势。

    2、高并发处理能力

    得益于其独特的设计,Nginx能够轻松处理数万甚至数十万的并发连接,而不会对性能造成太大影响。这一点对于流量高峰期的网站尤为重要,它可以保证用户在任何时候访问网站都能获得良好的体验。

    3、灵活的配置

    Nginx的配置文件非常灵活,支持各种复杂的设置。无论是负载均衡、缓存静态内容,还是SSL/TLS加密,Nginx都能通过简单的配置来实现。这种灵活性使得Nginx可以轻松适应各种不同的使用场景。

    4、社区支持与模块扩展

    Nginx拥有一个活跃的开发社区,不断有新的功能和优化被加入到官方版本中。此外,Nginx还支持第三方模块,这些模块可以扩展Nginx的功能,使其更加强大和多样化。

    5、广泛的应用场景

    从传统的Web服务器到反向代理、负载均衡器,再到API网关,Nginx几乎可以应用于任何需要处理HTTP请求的场景。它的可靠性和多功能性使得它成为了许多大型互联网公司的基础设施中不可或缺的一部分。

    Nginx以其卓越的性能和灵活的配置,赢得了全球开发者的青睐。它不仅仅是一个简单的Web服务器,更是一个强大的工具,能够帮助我们构建更加稳定、高效的网络应用。

    无论是初创公司还是大型企业,Nginx都能在其中发挥重要作用。那么,你准备好探索Nginx的世界了吗?让我们一起开启这场技术之旅吧!

    收起阅读 »

    502故障,你是怎么解决的?

    在现代网络应用的开发和维护中,502 Bad Gateway错误是一个常见而令人头疼的问题。这种错误通常意味着代理服务器或网关在尝试访问上游服务器时未能获取有效的响应。本文将深入探讨502故障的原因、可能的解决方案,并提供基于实际案例的客观凭证。 1. 原因深...
    继续阅读 »

    在现代网络应用的开发和维护中,502 Bad Gateway错误是一个常见而令人头疼的问题。这种错误通常意味着代理服务器或网关在尝试访问上游服务器时未能获取有效的响应。本文将深入探讨502故障的原因、可能的解决方案,并提供基于实际案例的客观凭证。


    1. 原因深入解析


    a. 上游服务器问题


    502错误的最常见原因之一是上游服务器出现问题。这可能包括服务器崩溃、过载、应用程序错误或者数据库连接故障。具体而言,通过观察服务器的系统日志、应用程序日志以及数据库连接状态,可以深入分析问题的根本原因。


    b. 网络问题


    网络中断、代理服务器配置错误或者防火墙问题都可能导致502错误。使用网络诊断工具,如traceroute或ping,可以检查服务器之间的连接是否畅通。同时,审查代理服务器和防火墙的配置,确保网络通信正常。


    c. 超时问题


    502错误还可能是由于上游服务器响应时间超过了网关或代理服务器的超时设置而引起的。深入了解请求的性能特征和服务器响应时间,调整超时设置可以是一项有效的解决方案。


    2. 解决方案的客观凭证


    a. 上游服务器状态监控


    使用监控工具,例如Prometheus、New Relic或Datadog,对上游服务器进行状态监控。通过设置警报规则,可以及时发现服务器性能下降或者异常情况。


    b. 网络连接分析


    借助Wireshark等网络分析工具,捕获和分析服务器之间的网络通信数据包。这有助于定位网络中断、数据包丢失或防火墙阻塞等问题。


    c. 超时设置调整


    通过监控工具收集请求的响应时间数据,识别潜在的性能瓶颈。根据实际情况,逐步调整代理服务器的超时设置,以确保其适应上游服务器的响应时间。


    3. 实例代码分析


    循环引用问题


    gc_enabled 是否开启gc
    gc_active 垃圾回收算法是否运行
    gc_full 垃圾缓冲区是否满了,在debug模式下有用
    buf 垃圾缓冲区,php7默认大小为10000个节点位置,第0个位置保留,既不会使用
    roots: 指向缓冲区中最新加入的可能是垃圾的元素
    unused 指向缓冲区中没有使用的位置,在没有启动垃圾回收算法前,指向空
    first_unused 指向缓冲区第一个为未使用的位置。新的元素插入缓冲区后,指向会向后移动一位
    last_unused 指向缓冲区最后一个位置
    to_free 带释放的列表
    next_to_free 下一个待释放的列表
    gc_runs 记录gc算法运行的次数,当缓冲区满了,才会运行gc算法
    collected 记录gc算法回收的垃圾数

    Nginx配置


    location / {
    proxy_pass http://backend_server;

    proxy_connect_timeout 5s;
    proxy_read_timeout 30s;
    proxy_send_timeout 12s;

    # 其他代理配置项...
    }

    上述Nginx配置中,通过设置proxy_connect_timeoutproxy_read_timeoutproxy_send_timeout,可以调整代理服务器的超时设置,从而适应上游服务器的响应时间。


    PHP代码


    try {
    // 执行与上游服务器交互的操作
    // ...

    // 如果一切正常,输出响应
    echo "Success!";
    } catch (Exception $e) {
    // 捕获异常并处理
    header("HTTP/1.1 502 Bad Gateway");
    echo "502 Bad Gateway: " . $e->getMessage();
    }

    在PHP代码中,通过捕获异常并返回502错误响应,实现了对异常情况的处理,提高了系统的健壮性。


    4. 结语


    502 Bad Gateway错误是一个综合性的问题,需要从多个角度进行深入分析。通过监控、网络分析和超时设置调整等手段,可以提高对502故障的解决效率。在实际应用中,结合客观的凭证和系统实时监控,开发者和运维人员能够更加迅速、准确地定位问题,确保网络应用的稳定性和可用性。通过以上深度透析和实际案例的代码分析,我们希望读者能够更好地理解502错误,并在面对此类问题时能够快速而有效地解决。


    作者:Student_Li
    来源:juejin.cn/post/7328766815101108243
    收起阅读 »

    转转流量录制与回放的原理及实践

    1 需求背景 随着转转业务规模和复杂度不断提高,业务服务增加,服务之间依赖关系也逐渐复杂。在开发和测试中遇到如下问题: 参数构造:转转接口测试平台可以很方便调用SCF(转转RPC框架),但是接口的参数很多是复杂model,手动构造参数成本不小,希望能够录制稳...
    继续阅读 »

    1 需求背景


    随着转转业务规模和复杂度不断提高,业务服务增加,服务之间依赖关系也逐渐复杂。在开发和测试中遇到如下问题:



    • 参数构造:转转接口测试平台可以很方便调用SCF(转转RPC框架),但是接口的参数很多是复杂model,手动构造参数成本不小,希望能够录制稳定测试环境流量,从流量中抽取接口参数,方便使用者选择参数进行接口测试。

    • 压测流量构造:转转是二手电商平台,有许多促销活动的压测需求,人工构造压测流量既不能模拟真实访问,又成本高昂。所以有录制线上流量的需求,然后压测平台通过策略二次加工形成压测case。

    • 自动化回归测试:业务迭代速度很快,每次迭代会不会影响原有逻辑?希望有一个平台能够提供筛选保存case,自动化回归,case通过率报告通知等能力。


    这些问题每个互联网公司都会遇到,如何才能优雅解决这些问题呢?首先定义一下优雅:不增加业务成本,业务基本无感,对业务性能影响要足够小。阿里开源的jvm-sandbox-repeater(简称Repeater)正是为解决这些问题而生,能够做到业务无感,但是性能问题需要特别定制处理。本文重点介绍:



    • Repeater流量录制和回放业务无感实现原理(第2、3章节)

    • 线上服务流量录制时,如何减少对正常业务的性能影响(第4章节)


    希望能够揭秘Repeater如何做到业务无感的流量录制和回放,进而让使用流量录制的同学对Repeater内部做了哪些工作以及对性能有哪些影响做到心中有数,最后介绍在流量录制时,为了保证对线上服务的性能影响相对可控,我们做了哪些工作,让大家会用敢用。


    2 流量录制和回放概念


    2.1 流量录制


    对于Java调用,一次流量录制包括一次入口调用(entranceInvocation)(eg:HTTP/Dubbo/Java)和若干次子调用(subInvocations)。流量的录制过程就是把入口调用和子调用绑定成一次完整的记录。


        /**
    * 获取商品价格,先从redis中获取,如果redis中没有,再用rpc调用获取,
    *
    @param productId
    *
    @return
    */

    public Integer getProductPrice(Long productId){ //入口调用

    //1.redis获取价格
    Integer price = redis.get(productId); //redis远程子调用
    if(Objects.isNull(price)){
    //2.远程调用获取价格
    price = daoRpc.getProductCount(productId); //rpc远程子调用
    redis.set(productId, price); //redis远程子调用
    }
    //3.价格策略处理
    price = process(price); //本地子调用
    return price;

    }

    private Integer process(Long price){
    //价格策略远程调用
    return logicRpc.process(productId); //rpc远程子调用
    }

    getProductPrice流量录制图解


    以获取产品价格方法为例,流量录制的内容简单来说是入口调用(getProductPrice)的入参和返回值,远程子调用(redis.get,daoRpc.getProductCount,redis.set,logicRpc.process)的入参和返回值,用于流量回放。注意并不会录制本地子调用(process)。


    下图是转转流量回放平台录制好的单个流量的线上效果,帮助理解流量录制概念。
    流量录制


    2.2 流量回放


    流量回放,获取录制流量的入口调用入参,再次发起调用,并且对于子调用,直接使用流量录制时记录的入参和返回值,根据入参(简单来说)匹配子调用后,直接返回录制的数据。这样就还原了流量录制时的环境,如果回放后返回值和录制时返回值不一致,那么本条回放case标记为失败。
    还以getProductPrice为例,假设录制时入口调用参数productId=1,返回值为1;redis.get子调用参数productId=1,返回值为1。那么回放时,redis.get不会访问redis,而是直接返回1。假设该函数有逻辑更新,回放返回值是2,与录制时返回值1不相等,那么次此流量回放标记为失败。


    下图是转转流量回放平台的流量回放的线上效果,帮助理解流量回放概念
    流量回放


    明白流量录制和回放概念后,下面看看业务无感实现流量录制和回放的实现原理。


    3 Repeater实现原理


    Repeater架构图



    • Repeater Console模块

      • 流量录制和回放的配置管理

      • 心跳管理

      • 录制和回放调用入口



    • Repeater agent plugin模块:Repeater核心功能是流量录制回放,其实现核心是agent插件,开源框架已经实现redis、mybatis、http、okhttp、dubbo、mq、guava cache等插件。由于录制和回放逻辑是以字节码增强的方式在程序运行时织入,所以无需业务编码。换句话说,agent技术是业务无感的关键。


    下面我们就进入无感的关键环节,介绍Repeater如何织入流量录制和回放逻辑代码,以及梳理流量录制和回放的核心代码。


    3.1 流量录制和回放逻辑如何织入


    用一句话来说,Repeater本身并没有实现代码织入功能,它依赖另一个阿里开源项目JVM-Sandbox。详细来讲,Repeater的核心逻辑录制协议基于JVM-Sandbox的BEFORERETRUNTHROW事件机制进行录制流程控制。本质上来说,JVM-Sandbox实现了java agent级别的spring aop功能,是一个通用增强框架。JVM-Sandbox的基于字节码增强的事件机制原理见下图:JVM-Sandbox事件机制


    上图以add方法为例,揭示JVM-Sandbox增强前后的代码变化,方便大家理解。下面的代码是对图中增强代码相关重点的注释


    public int add(int a, int b) {
    try {
    Object[] params = new Object[]{a, b};
    //BEFORE事件
    Spy.Ret retOnBefore = Spy.onBefore(10001,
    "com.taobao.test.Test", "add", this, params);
    //BEFORE结果可以直接返回结果或者抛出异常,是实现mock(阻断真实远程调用)的关键
    if (retOnBefore.state == I_RETURN) return (int) retOnBefore.object;
    if (retOnBefore.state == I_THROWS) throws(Throwable) retOnBefore.object;
    a = params[0];
    b = params[1];
    int r = a + b;
    //RETRUN事件
    Spy.Ret retOnReturn = Spy.onReturn(10001, r);
    if (retOnReturn.state == I RETURN)return (int) retOnReturn.object;
    if (retOnReturn.state == I_THROWS) throws(Throwable) retOnReturn.object;
    return r;
    } catch (Throwable cause) {
    //THROW事件
    Spy.Ret retOnIhrows = Spy.onThrows(10001, cause);
    if (retOnThrows.state == I RETURN)return (int) retOnThrows.object;
    if (retOnThrows.state == I THROWS) throws(Throwable) retOnThrows.object;
    throws cause;
    }
    }

    由上可知,Repeater是利用jvm agent字节码增强技术为目标方法织入BEFORERETRUNTHROW逻辑。


    3.2 流量录制和回放的核心代码


    既然Repeater利用JVM-Sandbox aop框架编写流量录制和回放逻辑,那么让我们看看它的核心代码doBefore。先来一张流程图。


    录制和回放插件逻辑图解


    再重点介绍doBeforedoMock的核心代码,它们是实现录制和回放的关键,注意阅读注释。为了方便理解,我对开源代码做了大量删减,只保留核心逻辑。


        /**
    * 处理before事件
    * 流量录制时记录函数元信息和参数,缓存录制数据
    * 流量回放时,调用回放逻辑,直接返回录制时的数据,后面会对processor.doMock进行展开讲解
    *
    @param event before事件
    */

    protected void doBefore(BeforeEvent event) throws ProcessControlException {
    // 回放流量;如果是入口则放弃;子调用则进行mock
    if (RepeatCache.isRepeatFlow(Tracer.getTraceId())) {
    processor.doMock(event, entrance, invokeType);
    return;
    }
    //非回放流量,进行流量录制,主要元信息、参数、返回值
    Invocation invocation = initInvocation(event);
    //记录是否为入口流量
    invocation.setEntrance(entrance);
    //记录参数
    invocation.setRequest(processor.assembleRequest(event));
    //记录返回值
    invocation.setResponse(processor.assembleResponse(event));

    }

    @Override
    public void doMock(BeforeEvent event, Boolean entrance, InvokeType type) throws ProcessControlException {

    try {

    //通过录制数据构建mock请求
    final MockRequest request = MockRequest.builder().build();
    //执行mock动作
    final MockResponse mr = StrategyProvider.instance().provide(context.getMeta().getStrategyType()).execute(request);
    //根据mock结果,阻断真实远程调用
    switch (mr.action) {
    case SKIP_IMMEDIATELY:
    break;
    case THROWS_IMMEDIATELY:
    //直接抛出异常,映射到JVM-Sandbox的事件机制原理的add函数
    //也就是代码走到if (retOnBefore.state == I_THROWS) throws(Throwable) retOnBefore.object;
    //而不再执行后面的代码(JVM-Sandbox框架机制,调用如下代码会触发阻断真实调用)
    ProcessControlException.throwThrowsImmediately(mr.throwable);
    break;
    case RETURN_IMMEDIATELY:
    //直接返回录制结果,映射到JVM-Sandbox的事件机制原理的add函数,同理,也不再执行后面的代码(阻断真实调用)
    ProcessControlException.throwReturnImmediately(assembleMockResponse(event, mr.invocation));
    break;
    default:
    ProcessControlException.throwThrowsImmediately(new RepeatException("invalid action"));
    break;
    }
    } catch (ProcessControlException pce) {
    throw pce;
    } catch (Throwable throwable) {
    ProcessControlException.throwThrowsImmediately(new RepeatException("unexpected code snippet here.", throwable));
    }
    }

    通过上面的2、3章节介绍了Repeater流量录制和回放业务无感的实现原理,下面说一下应用过程中需要哪些改造点。


    4 Repeater落地实践


    4.1 改造点



    • Rpeater开源管理后台仅仅是个Demo,需要重新设计和实现。

    • SCF(转转RPC框架)插件扩展,支持SCF应用的流量录制和回放。

    • DB由MySQL改造为ES,Repeater原生使用MySQL作为流量录制和回放的数据库,仅用于Demo演示,性能和容量无法满足实际需求。

    • Docker环境下频繁更换ip时不中断录制。

    • 回放结果Diff支持字段过滤。

    • 大批量回放。

    • 线上环境录制。


    4.2 线上环境录制


    流量录制很大一部分应用场景在线下,但是线上也有录制场景。从流量录制的原理可知,由于要记录入口调用和各种远程子调用,开启流量录制后,对于该请求占用内存资源会大大增加,并且会增加耗cpu的序列化操作(用于上报流量录制结果)。既然流量录制是一个天然的耗内存和性能操作,对于线上服务的录制除了保持敬畏之心之外,还有设计一种机制减少录制时对线上服务的性能影响。下面开始介绍如果做到录制时减少对线上服务性能的影响。


    线上录制减少性能影响的方案:



    • 从流程上,线上录制需要申请。

    • 从技术上,与发布系统联动,为录制服务增加专门的节点进行录制,并且设置权重为正常节点的1/10,正常节点不会挂载流量录制代码。

    • 从回滚上,如果线上录制节点遇到问题,可以从发布系统直接删除录制节点。


      线上录制效果



    5 总结


    本文旨在介绍Repeater流量录制和回放的实现原理,以及在落地过程中改造点,希望达到让大家懂原理、会使用、敢使用的目的。


    作者:转转技术团队
    来源:juejin.cn/post/7327538517528068106
    收起阅读 »

    Java 世界的法外狂徒:反射

    概述 反射(Reflection)机制是指在运行时动态地获取类的信息以及操作类的成员(字段、方法、构造函数等)的能力。通过反射,我们可以在编译时期未知具体类型的情况下,通过运行时的动态查找和调用。 虽然 Java 是静态的编译型语言,但是反射特性的加入,提供...
    继续阅读 »

    Reflection Title


    概述


    反射(Reflection)机制是指在运行时动态地获取类的信息以及操作类的成员(字段、方法、构造函数等)的能力。通过反射,我们可以在编译时期未知具体类型的情况下,通过运行时的动态查找和调用。 虽然 Java 是静态的编译型语言,但是反射特性的加入,提供一种直接操作对象外的另一种方式,让 Java 具备的一些灵活性和动态性,我们可以通过本篇文章来详细了解它


    为什么需要反射 ?


    Java 需要用到反射的主要原因包括以下几点:



    1. 运行时动态加载,创建类:Java中的类是在编译时加载的,但有时希望在运行时根据某些条件来动态加载和创建所需要类。反射就提供这种能力,这样的能力让程序可以更加的灵活,动态

    2. 动态的方法调用:根据反射获取的类和对象,动态调用类中的方法,这对于一些类增强框架(例如 Spring 的 AOP),还有安全框架(方法调用前进行权限验证),还有在业务代码中注入一些通用的业务逻辑(例如一些日志,等,动态调用的能力都非常有用

    3. 获取类的信息:通过反射,可以获取类的各种信息,如类名、父类、接口、字段、方法等。这使得我们可以在运行时检查类的属性和方法,并根据需要进行操作


    一段示例代码


    以下是一个简单的代码示例,展示基本的反射操作:


    import java.lang.reflect.Method;

    public class ReflectionExample {
    public static void main(String[] args) {
    // 假设在运行时需要调用某个类的方法,但该类在编译时未知
    String className = "com.example.MyClass";

    try {
    // 使用反射动态加载类
    Class<?> clazz = Class.forName(className);

    // 使用反射获取指定方法
    Method method = clazz.getMethod("myMethod");

    // 使用反射创建对象
    Object obj = clazz.newInstance();

    // 使用反射调用方法
    method.invoke(obj);

    } catch (ClassNotFoundException e) {
    System.out.println("类未找到:" + className);
    } catch (NoSuchMethodException e) {
    System.out.println("方法未找到");
    } catch (IllegalAccessException | InstantiationException e) {
    System.out.println("无法实例化对象");
    } catch (Exception e) {
    System.out.println("其他异常:" + e.getMessage());
    }
    }
    }

    在这个示例中,我们假设在编译时并不知道具体的类名和方法名,但在运行时需要根据动态情况来加载类、创建对象并调用方法。使用反射机制,我们可以通过字符串形式传递类名,使用 Class.forName() 动态加载类。然后,通过 getMethod() 方法获取指定的方法对象,使用 newInstance() 创建类的实例,最后通过 invoke() 方法调用方法。


    使用场景


    技术再好,如果无法落地,那么始终都是空中楼阁,在日常开发中,我们常常可以在以下的场景中看到反射的应用:



    1. 框架和库:许多框架和库使用反射来实现插件化架构或扩展机制。例如,Java 的 Spring 框架使用反射来实现依赖注入(Dependency Injection)和 AOP(Aspect-Oriented Programming)等功能。

    2. ORM(对象关系映射):ORM 框架用于将对象模型和关系数据库之间进行映射。通过反射,ORM 框架可以在运行时动态地读取对象的属性和注解信息,从而生成相应的 SQL 语句并执行数据库操作。

    3. 动态代理:动态代理是一种常见的设计模式,通过反射可以实现动态代理。动态代理允许在运行时创建代理对象,并拦截对原始对象方法的调用。这在实现日志记录、性能统计、事务管理等方面非常有用

    4. 反射调试工具:在开发和调试过程中,有时需要查看对象的结构和属性,或者动态调用对象的方法来进行测试。反射提供了一种方便的方式来检查和操作对象的内部信息,例如使用getDeclaredFields()获取对象的所有字段,或使用getMethod()获取对象的方法

    5. 单元测试:在单元测试中,有时需要模拟或替换某些对象的行为,以便进行有效的测试。通过反射,可以在运行时创建对象的模拟实例,并在测试中替换原始对象,以便控制和验证测试的行为


    Class 对象


    Class 对象是反射的第一步,我们先从 Class 对象聊起,因为在反射中,只要你想在运行时使用类型信息,就必须先得到那个 Class 对象的引用,他是反射的核心,它代表了Java类的元数据信息,包含了类的结构、属性、方法和其他相关信息。通过Class对象,我们可以获取和操作类的成员,实现动态加载和操作类的能力。


    常见的获取 Class 对象的方式几种:


    // 使用类名获取
    Class<?> clazz = Class.forName("com.example.MyClass");

    // 使用类字面常量获取
    Class<?> clazz = MyClass.class;

    // 使用对象的 getClass() 方法获取
    MyClass obj = new MyClass();
    Class<?> clazz = obj.getClass();


    需要注意的是,如果 Class.forName() 找不到要加载的类,它就会抛出异常 ClassNotFoundException



    正如上面所说,获取 Class 对象是第一步,一旦获取了Class对象,我们可以使用它来执行各种反射操作,例如获取类的属性、方法、构造函数等。示例:


    String className = clazz.getName(); // 获取类的全限定名
    int modifiers = clazz.getModifiers(); // 获取类的修饰符,如 public、abstract 等
    Class<?> superClass = clazz.getSuperclass(); // 获取类的直接父类
    Class<?> superClass = clazz.getSuperclass(); // 获取类的直接父类
    Class<?>[] interfaces = clazz.getInterfaces(); // 获取类实现的接口数组
    Constructor<?>[] constructors = clazz.getConstructors(); // 获取类的公共构造函数数组
    Method[] methods = clazz.getMethods(); // 获取类的公共方法数组
    Field[] fields = clazz.getFields(); // 获取类的公共字段数组
    Object obj = clazz.newInstance(); // 创建类的实例,相当于调用无参构造函数

    上述示例仅展示了Class对象的一小部分使用方法,还有许多其他方法可用于获取和操作类的各个方面。通过Class对象,我们可以在运行时动态地获取和操作类的信息,实现反射的强大功能。


    类型检查


    在反射的代码中,经常会对类型进行检查和判断,从而对进行对应的逻辑操作,下面介绍几种 Java 中对类型检查的方法


    instanceof 关键字


    instanceof 是 Java 中的一个运算符,用于判断一个对象是否属于某个特定类或其子类的实例。它返回一个布尔值,如果对象是指定类的实例或其子类的实例,则返回true,否则返回false。下面来看看它的使用示例


    1:避免类型转换错误


    在进行强制类型转换之前,使用 instanceof 可以检查对象的实际类型,以避免类型转换错误或 ClassCastException 异常的发生:


    if (obj instanceof MyClass) {
    MyClass myObj = (MyClass) obj;
    // 执行针对 MyClass 类型的操作
    }

    2:多态性判断


    使用 instanceof 可以判断对象的具体类型,以便根据不同类型执行不同的逻辑。例如:


    if (animal instanceof Dog) {
    Dog dog = (Dog) animal;
    dog.bark();
    } else if (animal instanceof Cat) {
    Cat cat = (Cat) animal;
    cat.meow();
    }

    3:接口实现判断


    在使用接口时,可以使用 instanceof 判断对象是否实现了某个接口,以便根据接口进行不同的处理


    if (obj instanceof MyInterface) {
    MyInterface myObj = (MyInterface) obj;
    myObj.doSomething();
    }

    4:继承关系判断


    instanceof 可以用于判断对象是否是某个类的子类的实例。这在处理继承关系时非常有用,可以根据对象的具体类型执行相应的操作


    if (obj instanceof MyBaseClass) {
    MyBaseClass myObj = (MyBaseClass) obj;
    // 执行 MyBaseClass 类型的操作
    }

    instanceof 看似可以做很多事情,但是在使用时也有很多限制,例如:



    1. 无法和基本类型进行匹配:instanceof 运算符只能用于引用类型,无法用于原始类型

    2. 不能和 Class 对象类型匹配:只可以将它与命名类型进行比较

    3. 无法判断泛型类型参数:由于Java的泛型在运行时会进行类型擦除,instanceof 无法直接判断对象是否是某个泛型类型的实例



    instanceof 看似方便,但过度使用它可能表明设计上的缺陷,可能违反了良好的面向对象原则。应尽量使用多态性和接口来实现对象行为的差异,而不是过度依赖类型检查。



    isInstance() 函数


    java.lang.Class 类也提供 isInstance() 类型检查方法,用于判断一个对象是否是指定类或其子类的实例。更适合在反射的场景下使用,代码示例:


    Class<?> clazz = MyClass.class;
    boolean result = clazz.isInstance(obj);

    如上所述,相比 instanceof 关键字,isInstance() 提供更灵活的类型检查,它们的区别如下:



    1. isInstance() 方法的参数是一个对象,而 instanceof 关键字的操作数是一个引用类型。因此,使用 isInstance() 方法时,可以动态地确定对象的类型,而 instanceof 关键字需要在编译时指定类型。

    2. isInstance()方法可以应用于任何Class对象。它是一个通用的类型检查方法。而instanceof关键字只能应用于引用类型,用于检查对象是否是某个类或其子类的实例。

    3. isInstance()方法是在运行时进行类型检查,它的结果取决于实际对象的类型。而instanceof关键字在编译时进行类型检查,结果取决于代码中指定的类型。

    4. 由于Java的泛型在运行时会进行类型擦除,instanceof无法直接检查泛型类型参数。而isInstance()方法可以使用通配符类型(<?>)进行泛型类型参数的检查。


    总体而言,isInstance()方法是一个动态的、通用的类型检查方法,可以在运行时根据实际对象的类型来判断对象是否属于某个类或其子类的实例。与之相比,instanceof关键字是在编译时进行的类型检查,用于检查对象是否是指定类型或其子类的实例。它们在表达方式、使用范围和检查方式等方面有所差异。在具体的使用场景中,可以根据需要选择合适的方式进行类型检查。


    代理


    代理模式


    代理模式是一种结构型设计模式,其目的是通过引入一个代理对象,控制对原始对象的访问。代理对象充当了原始对象的中间人,可以在不改变原始对象的情况下,对其进行额外的控制和扩展。这是一个简单的代理模式示例:


    // 定义抽象对象接口
    interface Image {
    void display();
    }

    // 定义原始对象
    class RealImage implements Image {
    private String fileName;

    public RealImage(String fileName) {
    this.fileName = fileName;
    loadFromDisk();
    }

    private void loadFromDisk() {
    System.out.println("Loading image:" + fileName);
    }

    @Override
    public void display() {
    System.out.println("Displaying image:" + fileName);
    }
    }

    // 定义代理对象
    class ImageProxy implements Image {
    private String filename;
    private RealImage realImage;

    public ImageProxy(String filename) {
    this.filename = filename;
    }

    @Override
    public void display() {
    if (realImage == null) {
    realImage = new RealImage(filename);
    }
    realImage.display();
    }
    }

    public class ProxyPatternExample {
    public static void main(String[] args) {
    // 使用代理对象访问实际对象
    Image image = new ImageProxy("test_10mb.jpg");
    // 第一次访问,加载实际对象
    image.display();
    // 第二次访问,直接使用已加载的实际对象
    image.display();
    }
    }

    输出结果:


    Loading image:test_10mb.jpg
    Displaying image:test_10mb.jpg
    Displaying image:test_10mb.jpg

    在上述代码中,我们定义了一个抽象对象接口 Image,并有两个实现类:RealImage 代表实际的图片对象,ImageProxy 代表图片的代理对象。在代理对象中,通过控制实际对象的加载和访问,实现了延迟加载和额外操作的功能。客户端代码通过代理对象来访问图片,实现了对实际对象的间接访问。


    动态代理


    Java的动态代理是一种在运行时动态生成代理类和代理对象的机制,它可以在不事先定义代理类的情况下,根据接口或父类来动态创建代理对象。动态代理使用Java的反射机制来实现,通过动态生成的代理类,可以在方法调用前后插入额外的逻辑。


    以下是使用动态代理改写上述代码的示例:


    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Method;
    import java.lang.reflect.Proxy;

    // 定义抽象对象接口
    interface Image {
    void display();
    }

    // 定义原始对象
    class RealImage implements Image {
    private String filename;

    public RealImage(String filename) {
    this.filename = filename;
    loadFromDisk();
    }

    private void loadFromDisk() {
    System.out.println("Loading image: " + filename);
    }

    public void display() {
    System.out.println("Displaying image: " + filename);
    }
    }

    // 实现 InvocationHandler 接口的代理处理类
    class ImageProxyHandler implements InvocationHandler {

    private Object realObject;

    public ImageProxyHandler(Object realObject) {
    this.realObject = realObject;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Object result = null;
    if (method.getName().equals("display")) {
    System.out.println("Proxy: before display");
    result = method.invoke(realObject, args);
    System.out.println("Proxy: after display");
    }
    return result;
    }
    }

    public class DynamicProxyExample {

    public static void main(String[] args) {
    // 创建原始对象
    Image realImage = new RealImage("image.jpg");
    // 创建动态代理对象
    Image proxyImage = (Image) Proxy.newProxyInstance(Image.class.getClassLoader(), new Class[]{Image.class}, new ImageProxyHandler(realImage));
    // 使用代理对象访问实际对象
    proxyImage.display();
    }
    }

    在上述代码中,我们使用 java.lang.reflect.Proxy 类创建动态代理对象。我们定义了一个 ImageProxyHandler 类,实现了 java.lang.reflect.InvocationHandler 接口,用于处理代理对象的方法调用。在 invoke() 方法中,我们可以在调用实际对象的方法之前和之后执行一些额外的逻辑。


    输出结果:


    Loading image: image.jpg
    Proxy: before display
    Displaying image: image.jpg
    Proxy: after display

    在客户端代码中,我们首先创建了实际对象 RealImage,然后通过 Proxy.newProxyInstance() 方法创建了动态代理对象 proxyImage,并指定了代理对象的处理类为 ImageProxyHandler。最后,我们使用代理对象来访问实际对象的 display() 方法。


    通过动态代理,我们可以更加灵活地对实际对象的方法进行控制和扩展,而无需显式地创建代理类。动态代理在实际开发中常用于 AOP(面向切面编程)等场景,可以在方法调用前后添加额外的逻辑,如日志记录、事务管理等。


    违反访问权限


    在 Java 中,通过反射机制可以突破对私有成员的访问限制。以下是一个示例代码,展示了如何使用反射来访问和修改私有字段:


    import java.lang.reflect.Field;

    class MyClass {
    private String privateField = "Private Field Value";
    }

    public class ReflectionExample {

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
    MyClass myObj = new MyClass();
    // 获取私有字段对象
    Field privateField = MyClass.class.getDeclaredField("privateField");

    // 取消对私有字段的访问限制
    privateField.setAccessible(true);

    // 获取私有字段的值
    String fieldValue = (String) privateField.get(myObj);
    System.out.println("Original value of privateField: " + fieldValue);

    // 修改私有字段的值
    privateField.set(myObj, "New Field Value");

    // 再次获取私有字段的值
    fieldValue = (String) privateField.get(myObj);
    System.out.println("Modified value of privateField: " + fieldValue);
    }
    }

    在上述代码中,我们定义了一个 MyClass 类,其中包含一个私有字段 privateField。在 ReflectionExample 类的 main 方法中,我们使用反射获取了 privateField 字段,并通过 setAccessible(true) 方法取消了对私有字段的访问限制。然后,我们使用 get() 方法获取私有字段的值并输出,接着使用 set() 方法修改私有字段的值。最后,再次获取私有字段的值并输出,验证字段值的修改。


    输出结果:


    Original value of privateField: Private Field Value
    Modified value of privateField: New Field Value

    除了字段,通过反射还可以实现以下违反访问权限的操作:



    • 调用私有方法

    • 实例化非公开的构造函数

    • 访问和修改静态字段和方法

    • 绕过访问修饰符检查


    虽然反射机制可以突破私有成员的访问限制,但应该慎重使用。私有成员通常被设计为内部实现细节,并且具有一定的安全性和封装性。过度依赖反射访问私有成员可能会破坏代码的可读性、稳定性和安全性。因此,在使用反射突破私有成员限制时,请确保了解代码的设计意图和潜在风险,并谨慎操作。


    总结


    反射技术自 JDK 1.1 版本引入以来,一直被广泛使用。它为开发人员提供了一种在运行时动态获取类的信息、调用类的方法、访问和修改类的字段等能力。在过去的应用开发中,反射常被用于框架、工具和库的开发,以及动态加载类、实现注解处理、实现代理模式等场景。反射技术为Java的灵活性、可扩展性和动态性增添了强大的工具。


    当下,反射技术仍然发挥着重要的作用。它被广泛应用于诸多领域,如框架、ORM(对象关系映射)、AOP(面向切面编程)、依赖注入、单元测试等。反射技术为这些领域提供了灵活性和可扩展性,使得开发人员能够在运行时动态地获取和操作类的信息,以实现更加灵活和可定制的功能。同时,许多流行的开源框架和库,如 Spring、Hibernate、JUnit 等,也广泛使用了反射技术。


    反射技术可能继续发展和演进。随着 Java 平台的不断发展和语言特性的增强,反射技术可能会在性能优化,安全性,模块化等方面进一步完善和改进反射的应用。然而,需要注意的是,反射技术应该谨慎使用。由于反射涉及动态生成代码、绕过访问限制等操作,如果使用不当,可能导致代码的可读性和性能下降,甚至引入安全漏洞。因此,开发人员在使用反射时应该充分理解其工作原理和潜在的风险,并且遵循最佳实践。


    作者:小二十七
    来源:juejin.cn/post/7235513984556220476
    收起阅读 »

    防御性编程失败,我开始优化我写的多重 if-else 代码

    前言 最近防御性编程比较火,不信邪的我在开发中我进行了尝试,然后我写下了如下的代码: public static void main(String[] args) { // do something if ("满足条...
    继续阅读 »

    前言



    • 最近防御性编程比较火,不信邪的我在开发中我进行了尝试,然后我写下了如下的代码:


        public static void main(String[] args) {
    // do something
    if ("满足条件A") {
    // 查询权限
    if ("是否具备权限A" && "是否具备权限B") {
    // 查询配置
    if ("配置是否开启"){
    // do something
    }
    }
    }
    // do something
    }


    • 不出意外我被逮捕了,组内另外一位同事对我的代码进行了 CodeReview,我的防御性编程编程没有幸运逃脱,被标记上了“多重 if-else ”需要进行优化,至此我的第一次防御性编程失败,开始了优化多重 if-else 之路,下面是我总结出的常用几种优化方式。


    版本



    • Java8


    几种常用的优化方式


    提前使用 return 返回去除不必要的 else



    • 如果我们的代码块中需要使用 return 返回,我们应该尽可能早的使用 return 返回而不是使用 else

    • 优化前


        private static boolean extracted(boolean condition) {
    if (condition) {
    // do something
    return false;
    }else {
    // do something
    return true;
    }
    }


    • 优化后


        private static boolean extracted(boolean condition) {
    if (condition) {
    // do something
    return false;
    }

    // do something
    return true;
    }

    使用三目运算符



    • 一些简单的逻辑我们可以使用三目运算符替代 if-else ,这样可以让我们的代码更加简洁

    • 优化前


            int num = 0;
    if (condition) {
    num = 1;
    } else {
    num = 2;
    }


    • 优化后


    int num = condition ? 1 : 2;

    使用枚举



    • 在某一些场景我们也可以使用枚举来优化多重 if-else 代码,使我们的代码更加简洁、具备更多的可读性和可维护性。

    • 优化前


            String OrderStatusDes;
    if (orderStatus == 0) {
    OrderStatusDes = "订单未支付";
    } else if (orderStatus == 1) {
    OrderStatusDes = "订单已支付";
    } else if (orderStatus == 2) {
    OrderStatusDes = "已发货";
    } else {
    throw new Exception("Invalid order status");
    }


    • 优化后


    public enum OrderStatusEnum {
    UN_PAID(0, "订单未支付"),
    PAIDED(1, "订单已支付"),
    SENDED(2, "已发货"),
    ;

    private final int code;
    private final String desc;

    public int getCode() {
    return code;
    }

    public String getDesc() {
    return desc;
    }

    OrderStatusEnum(int index, String desc) {
    this.code = index;
    this.desc = desc;
    }

    public static OrderStatusEnum getOrderStatusEnum(int orderStatusCode) {
    for (OrderStatusEnum statusEnum : OrderStatusEnum.values()) {
    if (statusEnum.getCode() == orderStatusCode) {
    return statusEnum;
    }
    }
    return null;
    }
    }


    // 当然你需要根据业务场景对异常值做出合适的处理
    OrderStatusEnum.getOrderStatusEnum(2)

    抽取条件判断作为单独的方法



    • 当我们某个逻辑条件判断比较复杂时,可以考虑将判断条件抽离为单独的方法,这样可以使我们主流程逻辑更加清晰

    • 优化前


            // do something
    if ("满足条件A" && "满足条件B") {
    // 查询权限
    if ("是否具备权限A" && "是否具备权限B") {
    // do something
    }
    }
    // do something


    • 优化后


        public static void main(String[] args) {
    // do something
    if (hasSomePermission()) {
    // do something
    }
    // do something
    }

    private static boolean hasSomePermission() {
    if (!"满足条件A" || !"满足条件B") {
    return false;
    }
    // 查询权限
    return "是否具备权限A" && "是否具备权限B";
    }

    有时候 switch 比 if-else 更加合适



    • 当条件为清晰的变量和枚举、或者单值匹配时,switch 比 if-else 更加合适,可以我们带好更好的可读性以及更好的性能 O(1)

    • 优化前


    if (day == Day.MONDAY) {
    // 处理星期一的逻辑
    } else if (day == Day.TUESDAY) {
    // 处理星期二的逻辑
    } else if (day == Day.WEDNESDAY) {
    // 处理星期三的逻辑
    } else if (day == Day.THURSDAY) {
    // 处理星期四的逻辑
    } else if (day == Day.FRIDAY) {
    // 处理星期五的逻辑
    } else if (day == Day.SATURDAY) {
    // 处理星期六的逻辑
    } else if (day == Day.SUNDAY) {
    // 处理星期日的逻辑
    } else {
    // 处理其他情况
    }


    • 优化后


    // 使用 switch 处理枚举类型
    switch (day) {
    case MONDAY:
    // 处理星期一的逻辑
    break;
    case TUESDAY:
    // 处理星期二的逻辑
    break;
    // ...
    default:
    // 处理其他情况
    break;
    }

    策略模式 + 简单工厂模式



    • 前面我们介绍一些常规、比较简单的优化方法,但是在一些更加复杂的场景(比如多渠道对接、多方案实现等)我们可以结合一些场景的设计模式来实现让我们的代码更加优雅和可维护性,比如策略模式 + 简单工厂模式。

    • 优化前


        public static void main(String[] args) {
    // 比如我们商场有多个通知渠道
    // 我们需要根据不同的条件使用不同的通知渠道
    if ("满足条件A") {
    // 构建渠道A
    // 通知
    } else if ("满足条件B") {
    // 构建渠道B
    // 通知
    } else {
    // 构建渠道C
    // 通知
    }
    }
    // 上面的代码不仅维护起来麻烦同时可读性也比较差,我们可以使用策略模式 + 简单工厂模式


    • 优化后


    import java.util.HashMap;
    import java.util.Map;

    // 定义通知渠道接口
    interface NotificationChannel {
    void notifyUser(String message);
    }

    // 实现具体的通知渠道A
    class ChannelA implements NotificationChannel {
    @Override
    public void notifyUser(String message) {
    System.out.println("通过渠道A发送通知:" + message);
    }
    }

    // 实现具体的通知渠道B
    class ChannelB implements NotificationChannel {
    @Override
    public void notifyUser(String message) {
    System.out.println("通过渠道B发送通知:" + message);
    }
    }

    // 实现具体的通知渠道C
    class ChannelC implements NotificationChannel {
    @Override
    public void notifyUser(String message) {
    System.out.println("通过渠道C发送通知:" + message);
    }
    }

    // 通知渠道工厂
    class NotificationChannelFactory {
    private static final Mapextends NotificationChannel>> channelMap = new HashMap<>();

    static {
    channelMap.put("A", ChannelA.class);
    channelMap.put("B", ChannelB.class);
    channelMap.put("C", ChannelC.class);
    }

    public static NotificationChannel createChannel(String channelType) {
    try {
    Classextends NotificationChannel> channelClass = channelMap.get(channelType);
    if (channelClass == null) {
    throw new IllegalArgumentException("不支持的通知渠道类型");
    }
    return channelClass.getDeclaredConstructor().newInstance();
    } catch (Exception e) {
    throw new RuntimeException("无法创建通知渠道", e);
    }
    }
    }

    // 客户端代码
    public class NotificationClient {
    public static void main(String[] args) {
    // 根据条件选择通知渠道类型
    String channelType = "A";
    // 使用简单工厂创建通知渠道
    NotificationChannel channel = NotificationChannelFactory.createChannel(channelType);

    // 执行通知
    channel.notifyUser("这是一条通知消息");
    }
    }


    • 有时候我们还可以借助 Spring IOC 能力的自动实现策略类的导入,然后使用 getBean() 方法获取对应的策略类实例,可以根据我们的实际情况灵活选择。


    如何优化开头的代码



    • 好了现在回到开头,如果是你会进行怎么优化,下面是我交出的答卷,大家也可以在评论区发表自己的看法,欢迎一起交流:


       public static void main(String[] args) {
    // do something
    if (isMeetCondition()) {
    // 查询配置
    // 此处查询配置的值需要在具体的任务中使用,所有并没抽离
    if ("配置是否开启") {
    // do something
    }
    }
    // do something
    }

    /**
    * 判断是否满足执行条件
    */

    private static boolean isMeetCondition() {
    if (!"满足条件A") {
    return false;
    }
    // 查询权限
    return "是否具备权限A" && "是否具备权限B";
    }



    作者:Lorin洛林
    来源:juejin.cn/post/7325353198591672359
    收起阅读 »

    火烧眉毛,我是如何在周六删了公司的数据库

    这本是一个安静的星期六。 我收到了支持团队的一条消息,说我们一个客户遇到了问题。我认为这个问题很重要,值得开始调试。15 分钟后,我明白了问题所在 - 在数据库中有一些损坏的订单需要删除。 听起来小菜一碟。 事故还原 如果你不给创业公司打工,请不要嘲笑我 😅 ...
    继续阅读 »


    这本是一个安静的星期六。


    我收到了支持团队的一条消息,说我们一个客户遇到了问题。我认为这个问题很重要,值得开始调试。15 分钟后,我明白了问题所在 - 在数据库中有一些损坏的订单需要删除。


    听起来小菜一碟。


    事故还原


    如果你不给创业公司打工,请不要嘲笑我 😅


    有几百个订单需要删除,所以我决定不手动操作,而是编写一个简单的 SQL 查询语句(警告 🚩)


    实际上比这复杂一些,但这里简化一下:


    UPDATE orders
    SET is_deleted = true

    WHERE id in (1, 2, 3)

    你大概已经猜到这场灾难的规模了...


    我按下了 CTRL + Enter 并运行了命令。当它花费超过一秒钟时,我明白发生了什么。我的客户端 DBeaver 看到空的第三行,并忽略了第四行。


    是的,我删除了数据库中所有的订单 😢


    我整个人都不好了。


    恢复


    深吸一口气后,我知道我必须快速行动起来。不能犯更多错误浪费时间了。


    恢复工作做得很好。



    1. 停止系统 - 约 5 分钟

    2. 创建变更前数据库(幸运的是我们有 PITR)的克隆 - 约 20 分钟

    3. 在等待期间给我的老板打电话 😨

    4. 根据克隆更新生产数据库的信息* - 约 15 分钟

    5. 启动系统 - 约 5 分钟


    *我决定不还原整个数据库,因为无法停止所有系统,因为我们有多个独立的系统。我不想在恢复过程中丢失所做的更改。我们用 GCP 提供的托管 PostgreSQL,所以我从更新之前创建了一个新的克隆。然后,我只导出了克隆中的 idis_deleted 列,并将结果导入到生产数据库中。之后,就是简单的 update + select 语句。


    所以显然本可以很容易避免这 45 分钟的停机时间...


    发生了什么?


    这可能听起来像是一个你永远不会犯的愚蠢错误(甚至在大公司中,根本不能犯)。确实。问题不在于错误的 SQL 语句。**一个小小的人为失误从来都不是真正的问题。**我运行那个命令只是整个失败链条的终点。



    1. 为什么要在周末处理生产环境?在这种情况下,事情并没有那么紧急。没有人要求我立即修复它。我本可以等到星期一再处理。

    2. 谁会在生产数据库上更改而不先在 QA 环境上运行一下呢?

    3. 为什么我手动编辑了数据库而不是通过调用 API?

    4. 如果没有 API,为什么我没打电话给队友,在如此敏感的操作上进行双重检查?

    5. **最糟糕的是,为什么我没使用事务?**其实只要用了 Begin,万一出错时使用 Rollback 就可以了。


    错误一层层叠加,其中任何一个被避免了 - 整件事就不会发生。大多数问题答案都很简单:我太自信了。
    不过还好通过有章法的恢复程序,阻止了连锁反应。想象一下如果无法将数据库恢复到正确状态会发生什么灾难……


    这与切尔诺贝利有什么关系?


    几个月前,我阅读了「切尔诺贝利:一部悲剧史」。那里发生的一系列错误使我想起了那个被诅咒的周末(并不是要低估或与切尔诺贝利灾难相比较)。



    1. RBMK 反应堆存在根本技术问题。

    2. 这个问题没有得到恰当传达。之前有涉及该问题的事件,但切尔诺贝利团队对此并不熟悉。

    3. 在安全检查期间,团队没有按程序操作。

    4. 爆炸后,苏联政府试图掩盖事实,从而大大加剧了损害程度。


    谁应该负责?


    反应堆设计师?其他电厂团队未能传达他们遇到的问题?切尔诺贝利团队?苏联政府?


    所有人都有责任。灾难从来不是由单一错误引起的,而是由一连串错误造成的。我们的工作就是尽早打断这条链条,并做到最好。


    后续


    我对周一与老板的谈话本没有什么期待。


    但他让我惊讶:「确保不再发生这种情况。但是我更喜欢这样 - 你犯了错误是因为你专注并且喜欢快速行动。做得越多,砸得越多。」


    那正是我需要听到的。如果以过于「亲切」的方式说:没关系,别担心,谢谢你修复它!我反而会感觉虚伪。另一方面,我已经感觉很糟糕了,所以没有必要进一步吐槽我。


    从那时起:



    • 我们减少了对数据库直接访问的需求,并创建相关的 API。

    • 我总是先在 QA 上运行查询(显而易见吧?没有比灾难更能教训人了)。

    • 我与产品经理商量,了解真正紧急和可以等待的事项。

    • 任何对生产环境进行更删改操作都需要两个人来完成。这实际上防止了其他错误!

    • 我开始使用事务处理机制。


    可以应用在你的团队中的经验教训


    事发后,我和团队详细分享了过程,没有隐瞒任何事情,也没有淡化我的过错。
    在责备他人和不追究责任之间有一个微妙的平衡。当你犯错误时,这是一个传递正确信息的好机会。


    如果你道歉 1000 次,他们会认为你期望当事情发生在他们身上时,他们也需要给出同样的回应。


    如果你一笑了之,并忽视其影响,他们会认为这是可以接受的。


    如果你承担责任、学习并改进自己 - 他们也会以同样的方式行事。


    file


    总结一下



    • 鼓励行动派,关心客户,并解决问题。这就是初创企业成功的方式。

    • 当犯错时,要追究责任。一起理解如何避免这种情况发生。

    • 没必要落井下石。有些人需要更多的责任感,而有些人则需要更多的鼓励。我倾向于以鼓励为主。


    顺便说一句,如果团队采用了 Bytebase 的话,这个事故是大概率可以被避免的,因为 Bytebase 有好几道防线:



    1. 用户不能随意通过使用 DBeaver 这样的本地客户端直连数据库,而必须通过 Bytebase 提交变更工单。

    2. 变更工单的 SQL 会经过自动审查,如果影响范围有异常,会有提示。

    3. 变更工单只有通过人工审核后才能发布。

    作者:Bytebase
    来源:juejin.cn/post/7322156771614507059
    收起阅读 »

    年底了,出了P0级故障,人肉运维不可靠

    翻车现场 5年前,大概是2018年12月份的一个晚上,我接到数据组同事的消息,要求将A用户的磁盘快照共享给B用户。我对这个线上运维工作早已轻车熟路,登录线上服务器仅用了2分钟就完成了。 我继续忙着其他事情,3分钟后,我正要打开新的控制台页面,猛然发现控制台上的...
    继续阅读 »

    翻车现场


    5年前,大概是2018年12月份的一个晚上,我接到数据组同事的消息,要求将A用户的磁盘快照共享给B用户。我对这个线上运维工作早已轻车熟路,登录线上服务器仅用了2分钟就完成了。


    我继续忙着其他事情,3分钟后,我正要打开新的控制台页面,猛然发现控制台上的“ public = true”。我惊慌地查看磁盘快照状态,发现磁盘快照已经共享给了所有用户。任何用户都可以在自己的快照列表中看到这个快照,并用快照创建新的磁盘,这意味着这些快照数据已经泄露了。这可是公司重要客户的磁盘数据啊!!!!


    我心里明白,对于云计算行业,数据安全问题比线上bug还要严重!


    我立刻就慌了,心脏砰砰的跳,手也开始颤抖。我心里很忐忑,一开始试图偷偷回滚,纠结之后,最终选择告诉了组长。


    我倒吸一口气,一边进行回滚,一边试图平静的说,“我把刚才的快照共享给了所有租户”。瞬间,组长瞪大眼睛了看着我,“回滚了吗,赶紧回滚……”。 我颤抖地编辑SQL,手都麻木了,心脏还在怦怦跳个不停,开始担心这件事的后果。


    领导边看我回滚,边小声对我说,“赶紧回滚,下次小心点”,看的出来,组长不想声张,他想先看看影响。


    ”嗯,好“,我努力嗯了一声,组长没大声骂我,我很感动。本以为回滚了,就没事了。



    (后来这家小公司黄了,这是被我干黄的第二家公司,你们干黄了几家?)



    然而,这远远没有结束。


    原本宁静的办公室突然变得热闹起来,周围的同事们纷纷接到了报警通知。他们“兴高采烈”地讨论着报警的原因,我的注意力也被吸引了过去,听起来似乎与我有关,但我却没有心情去理会他们。


    最终,快照被共享 5 分钟后,回滚完成,我长舒一口气,心想幸好我多看了一眼控制台,否则不知道被泄露多久。


    与此同时,邻居组的成员钱哥找到了我,问道:“刚才快照计费数据暴涨了,你们这边有做过什么操作吗?”


    随后,邻居组的组长王哥也过来了,询问情况如何。


    我的组长苦笑着告诉他们:“刚才一个磁盘快照错误地被共享给了所有租户,不过现在已经回滚了。”


    邻居组的王哥听后惊愕地说道:“卧槽,谁干的?”他的脸上露出了一丝微笑,似乎是看热闹的微笑。


    我实在不知道该怎么说了,苦着脸问他们:“计费数据能回滚吗?”


    邻居组的王哥没有回答我的问题,看了我一眼,说:“我叫上老板,先找个会议室讨论一下吧。”


    万幸的是这 5分钟里没有用户使用此快照创建磁盘,这意味快照数据没有发生实质性泄露。


    至暗时刻


    接下来的两天里,我只做了两件事,参加复盘会议和去会议室的路上。这两天是我人生中最难忘的时刻,我尴尬得连脚丫子都能拧成麻花。


    我真希望能立刻辞职离开这个地方。”别再鞭尸了,老子不干了,行不行。md,不就是共享个快照嘛!“ 我的心理状态从忐忑变得暴躁~



    (每次造成线上故障,我都有类似的想法,我不想干了,不就是个bug吗,不干了,还不行吗?你们有类似想法吗?)



    后来我开始后悔 ,为什么不早点下班,九点多还帮同事进行高危的线上操作,我图个啥


    对,我图个啥。我脑子被驴踢了,才提出这个人肉运维方案,一周运维十几次,自己坑自己……


    背景


    2个月前,组长接到一个大客户需求,要求在两个租户之间共享云磁盘数据,当时提出很多个方案,其中包括分布式存储系统提供工具共享两个云磁盘数据等非常复杂的方案。 我当时听到这个需求,就立马想到, 我们的云管理系统可以实现两个租户的资源共享啊,通过给云磁盘打快照、共享快照等,就实现两个云磁盘的数据共享。


    当时我非常得意,虽然我对存储并不是很了解,但是我相信我的方案比存储团队的底层方案更加简单且可行性更高。经过与客户的沟通,确定了这个方案能够满足他们的诉求,于是我们定下了这个方案。


    由于大客户要的比较急,我改了代码就急匆匆上线,这个需求甚至没有产品参与,当客户需要共享数据时,需要我构造请求参数,在线上服务器上命令行执行共享操作。第一版方案在线上验证非常顺利,客户对这样快速的交付速度非常满意


    因为我们使用了开源的框架,资源共享能力是现成的,所以改动起来很快。只不过有一个核弹级feature,我忽略了它的风险。


    public = true时,资源将共享给全部用户。“只要不设置这个参数就不会有什么问题。” 这是我的想法,我没有考虑误操作的可能,更没有想到自己会犯下这个错误。


    本以为只是低频的一次性操作,没想到后来客户经常性使用。我不得不一次次在线上执行高危操作,刚开始我非常小心谨慎,仔细的检查每个参数,反复确认后才执行命令。


    然而,后来我感到这个工作太过枯燥乏味,于是开始集中处理,一次性执行一批操作。随着时间的推移,我越来越熟悉这件事。这种运维操作我两分钟就能完成……之所以这么快,是因为我不再仔细检查参数,只是机械地构造参数,随手执行。正是我松懈的态度导致闯下了大祸,在那个日常性加班的晚上。


    后来我开始反思,从需求提出到故障发生前,我有哪些做的不对的地方。我认为有如下问题。



    1. 技术方案不能仅限于提供基本的资源共享能力,还要提供可视页面,提供产品化能力。

    2. 高危接口,一定要严格隔离成 单独的接口,不能和其他接口混合在一起,即使功能类似

    3. 线上重要操作要提供审核能力!或者有double check 的机制!


    深刻的反思


    任何工作都是有风险的,尤其是程序员无时无刻都在担心发生线上问题,如果不学会保护自己,那么多干一件事就多增加很多风险,增加背锅的风险。


    拿我来说,本来这个需求不需要我参与,我提出了一个更简单的方案,高效的响应了大客户需求,是给自己长脸的事情。然而,我犯了一个巨大的错误,之前所做的努力都付之一炬。大领导根本不知道我提出的方案更简洁高效,他只认为我办事不可靠。在复盘会议上,我给大领导留下了非常糟糕的印象。


    话说回来,在这个事情上如何保护自己呢?



    1. 技术方案一定要避免人肉运维,对于高危运维操作要求产品提供可视化页面运维。一定要尽全力争取,虽然很多时候,因为排期不足,前端资源不足等原因无法做到。

    2. 如果没有运维页面,等基础能力上线后,继续寻求组长帮助,协调产品提供操作页面,避免一直依赖自己人肉运维去执行高危操作。

    3. 在还没有产品化之前,要求客户或上游同事将所有的需求整理到文档上,使用文档进行沟通交流,记录自己的工作量,留存一份自己的”苦劳“。

    4. 在低频操作,变为高频操作时,不应该压迫自己更加“高效运维”,而是将压力和风险再次传达给产品和组长,让他们意识到我的人肉运维存在极大危险,需要要尽快提供产品化能力。让他们明白:“如果不尽快排期,他们也会承担风险!”

    5. 任何时候,对于线上高危操作,一定要小心谨慎。万万不可麻痹大意!


    总之,千万不要独自承担所有的压力和风险。在工作中,我们可以付出辛勤努力,承受一定的风险,但是必须得到相应的回报。



    风浪越大,鱼越贵。但是如果大风大浪,鱼还是很便宜,就不要出海了!风险收益要对等



    就这个事情来说,每天我都要执行高风险的运维操作,是一种辛苦而不太受重视的工作。尽管如此,我却必须承担着巨大的风险,并自愿地让自己不断追求更高效的人工运维方式。然而结果却让人啼笑皆非,我终究翻车了。实在是可笑。



    挣着卖白菜的钱,操着卖白粉的心,这是我的真实写照。



    吾日三省吾身、这事能不能不干、这事能不能明天干、这事能不能推给别人干。


    程序员不善于沟通,往往通过加班、忍一忍等方式默默地承担了很多苦活、脏活、累活。但是我们要明白,苦活可以,脏活等高风险的活 千万不要自己扛。


    你干好十件事不一定传到大领导耳朵里,但是你出了一次线上大事故,他肯定第一时间知道。


    好事不出门,坏事传千里。


    我们一定要对 高危的人工运维,勇敢说不!


    作者:五阳神功
    来源:juejin.cn/post/7285673629526753316
    收起阅读 »

    幻兽帕鲁Palworld服务端最佳一键搭建教程

    幻兽帕鲁Palworld最近彻底火了,忍不住也自己去搭建了一下,找了很多教程,发现还是目前找的这个最方便,结合腾讯云服务器新人优惠套餐,这里给出我搭建的详细步骤,包你轻松搞定。 此方案适合新用户,毕竟老用户购买服务器价格还是很贵的,自己如果已经是老用户了记得用...
    继续阅读 »

    幻兽帕鲁.jpg


    幻兽帕鲁Palworld最近彻底火了,忍不住也自己去搭建了一下,找了很多教程,发现还是目前找的这个最方便,结合腾讯云服务器新人优惠套餐,这里给出我搭建的详细步骤,包你轻松搞定。


    此方案适合新用户,毕竟老用户购买服务器价格还是很贵的,自己如果已经是老用户了记得用身边的人帮你买一个即可。


    服务器选择


    目前发现各大厂家都推出了自家的新人首单优惠,官方入场,最为致命!太便宜了,这里推荐三家主流的



    image.png


    腾讯云的点击进来后,可以看到很明显的一栏关于帕鲁游戏的,点击后面的前往部署就可以进入优惠的服务器了


    ,推荐新人使用66元这一档,我个人也是买了这档来测试。


    image.png



    阿里云也推出了幻兽帕鲁专属云服务器,还是针对新用户的,如果你进来看到的价格也是入下图这样,那推荐入手


    image.png



    华为云也推出新用户一个月的优惠价,一个比一个卷


    image.png


    教程推荐


    我这次操作的教程脚本是参考github.com/2lifetop/Pa… 这个项目
    之所以用这个教程因为足够简单,也有界面可视化来配置私服的参数,


    image.png


    搭建步骤详细说明


    这里我用的是腾讯云服务器,所以流程介绍腾讯云上面的搭建方式,如果你买的是其他家的也类似,核心步骤都是以下2点:



    • 一键安装脚本

    • 服务端配置(可选)

    • 端口8211开放


    服务器购买


    因为脚本推荐的是用 Debian 12,所以我购买腾讯云服务器的时候,直接选择了 Debian12带Docker的版本。


    image.png


    购买后就可以进入服务器的界面了,如果找不到,可以搜索轻量应用服务器


    image.png


    image.png


    这里你可以用第三方ssh登录或者直接直接网页登录都行。我推荐用第三方登录,我用的是FinalShell这个软件,我第一步是进入修改密码。


    image.png


    然后就用FinalShell登录上了,稳的一批。


    一键安装脚本


    以root用户登陆到服务器然后运行以下命令即可。该脚本目前只在Debian12系统上验证过。如果遇上非网络问题则请自行更换系统或者寻求其他解决方案。


    非root用户请先运行 sudo su命令。


    1.  wget -O PalServerInstall.sh https://www.xuehaiwu.com/wp-content/uploads/shell/Pal/PalServerInstall.sh --no-check-certificate && chmod +x PalServerInstall.sh && ./PalServerInstall.sh

    出现下面这个画面了,选择1安装即可


    image.png


    正常等待几分钟就可以安装好了, 不过我自己安装的时候出现过问题,提示安装失败,然后我就执行11删除,然后重新执行脚本安装就成功了。


    服务端配置(可选)


    因为搭建的是私服嘛,所以为了体验更加,这个脚本提供了在线参数修改,步骤也很简单
    先打开 http://www.xuehaiwu.com/Pal/
    把你想调整的参数自行设置


    image.png


    其中比较重要的配置有



    • 服务器名称

    • 服务器上允许的最大玩家数(上限为 32)

    • 用于授予管理员访问权限的密码

    • 普通玩家加入所需的密码


    如果要使用管理员命令需要加上管理员密码,普通玩家加入密码暂时不推荐设置,因为可能会造成玩家进不来。


    服务器配置生成也挺麻烦的,所以我简单的做了个生成网页。要修改哪个直接在网页上修改就行。配备了中文介绍。


    都设置好了就可以点击下面的【生成配置文件】,然后复制下生成的wget这一行命令。


    image.png


    然后切回到SSH那边,黏贴执行即可,这样就会生成一个叫 PalWorldSettings.ini配置文件,这个时候就重新执行下脚本命令 ./PalServerInstall.sh ,调出命令窗口,选择4 就行,这样就会覆盖配置了。


    修改之后不是立即生效的,要重启帕鲁的服务端才能生效,对应数字8


    端口8211开放


    到此还差最后一步,就是要开放8211端口,我们进入到腾讯云网页端,点击进入详情


    image.png


    切换到防火墙,配置两条,TCP、UDP端口8211开放即可。


    image.png


    到此就算搞定了服务端的搭建了,这时候复制下公网IP,一会要用到


    登录游戏


    游戏也是需要大家自己购买的,打开游戏后,会看到一个【加入多人游戏(专用服务器)】选项,点击这个


    8b463bab9f2b026c77afaf711f79448.png


    进来后看到底部这里了没,把你服务器公网的ip去替换下 :8211前面的ip数字即可
    比如我的ip是:106.54.6.86,那我输入的就是 106.54.6.86:8211


    image.png


    总结


    ok,到此就是我搭建幻兽帕鲁Palworld服务端的全部流程,这游戏还是挺有意思的,缺点是缝合怪,优点是缝的还不错,我昨天自己搭建完玩了2个小时,大部分在搭建我的房子,盖着停不下来哈哈,感觉可以盖个10层楼。


    499f598cf68efdf9486e23424e65f44.png


    别人盖的比我好看多了。


    image.png


    这游戏其实火起来还有一个梗:帕鲁大陆最不缺的就是帕鲁,你不干有的是帕鲁干。
    图片


    我体验了一下也发现很真实,在游戏里面和帕鲁交朋友哈哈哈,其实是在压榨它们,让它们帮我们干活,累倒了就换一个,帕鲁多的是不缺你一个。现实中我们不也是帕鲁吗,所以大家突然找到了共鸣。


    各位上班的时候就是帕鲁,下班了在游戏里面压榨帕鲁。


    作者:嘟嘟MD
    来源:juejin.cn/post/7328621062727122944
    收起阅读 »

    大厂真实 Git 开发工作流程

    记得之前也写过一篇类似的文章,来到我东后感觉不够详尽,很多流程还是太局限了。大厂的开发流程还是比较规范的。如果你来不及学习长篇大论的 git 文档,这篇文章够你入职时用一段时间了。 一、开发分支模型分类 目前所在部门使用是主要是四种:dev(开发)、test(...
    继续阅读 »

    记得之前也写过一篇类似的文章,来到我东后感觉不够详尽,很多流程还是太局限了。大厂的开发流程还是比较规范的。如果你来不及学习长篇大论的 git 文档,这篇文章够你入职时用一段时间了。


    一、开发分支模型分类


    目前所在部门使用是主要是四种:dev(开发)、test(测试)、uat(预发)、release(生产)



    小公司可能就一个 dev、一个 master 就搞定了,测试都是开发人员自己来🤣。



    二、开发主体流程



    1. 需求评审

    2. 开发排期

    3. 编码开发

    4. 冒烟测试(自检验)

    5. 冒烟通过,提交测试,合并代码到测试分支,部署测试环境

    6. 测试环境测试,开发修 bug

    7. 测试完成,提交预发,合并代码到预发分支,部署预发环境

    8. 预发环境测试,开发修 bug(修完的 bug 要重新走测试再走预发,这个下面会解释)

    9. 测试完成,产品验收

    10. 验收完成,提交生产,合并代码到生产分支,部署生产环境

    11. 生产运营(客户)验收

    12. 验收完成,结项


    三、具体操作


    1. 拉取代码


    一般都会在本地默认创建一个 master 分支


    git clone https://code.xxx.com/xxx/xxx.git

    2. 初次开发需求前,要先拉取生产/预发分支,然后基于这个分支之上,创建自己的特性分支进行开发


    git fetch origin release:release

    git checkout release

    git checkout -b feat-0131-jie

    此时,在你本地已经有了一个 release 分支对应着远程仓库的 release 分支,还有一个内容基于 release 分支的特性分支,之后便可以在这个特性分支上进行需求开发了。


    注意1:分支名称是有规范和含义的,不能乱取。

    推荐格式:分支责任-需求日期/需求号-开发人姓名,一般按部门规范来,常见的有以下几种。


      - feat:新功能

    - fix:修补bug

    - doc:文档

    - refactor:重构(即不是新增功能,也不是修改bug的代码变动)

    - test:测试

    - chore:构建过程或辅助工具的变动

    注意2:为啥拉取的是生产/预发分支

    之所以要拉取 release/uat 分支而不是拉取 dev/test,是因为后者可能包含着一些其他成员还未上线或者可能有 bug 的需求代码,这些代码没有通过验证,如果被你给拉取了,然后又基于此进行新的需求开发,那当你需求开发完成,而其他成员的需求还没上线,你将会把这些未验证的代码一起发送到 uat/release 上,导致一系列问题。


    3. 需求开发完成,提交&合并代码


    首先先在本地把新的改动提交,提交描述的格式可以参考着分支名的格式



    • 如果是新需求的提交,可以写成 "feat: 需求0131-新增账期"

    • 如果是 bug 修复,可以写成 "fix: 禅道3387-重复请求"


    git add .

    git commit -m "提交描述"

    此时,本地当前分支已经记录了你的提交记录,接下来进行代码合并了


    在代码合并之前,我们先要梳理一下我们应该如何对分支进行管理(非常重要!)


    1. 首先,我们需要认知到的是,每一个分支应该只对应一个功能,例如当我们开发需求 01 时,那么就创建一个 feat-01-jie 分支进行开发;开发需求 02 时,就另外创建一个 feat-02-jie 分支进行开发;修改生产环境的某个 bug 时,就创建 fix-jie-3378 进行开发,等等。


      这样做的目的是,能够把不同的功能/需求/修改分离开来。想象一下这样一个场景,如果有某些紧急的需求是需要提前上线的,而此时你的分支里既包含了这些紧急的需求,又包含了其他未开发好的需求,那么这两种需求就不能拆开来分别进行提测和上线了。


    2. 其次,在合并代码时,我们要将四种分支模型(dev、test、uat、release)作为参照物,而不是把关注点放在自己的分支上。比如我们要在 dev 上调试,那就需要把自己的分支合并到 dev 分支上;如果我们需要提测,则把自己的分支合并到 test 分支上,以此类推。


      即,我们要关注到,这四个环境的分支上,会有什么内容,会新增什么内容。切记不能反过来将这四个分支合并到自己的代码上!! 如果其他成员将自己的代码也提交到 dev 分支上,但是这个代码是没有通过验证的,此时你将 dev 往自己的分支上合,那之后的提测、上预发、生产则很大概率会出问题。所以一定要保持自己的分支是干净的!



    接下来介绍合并代码的方式:


    第一种:线上合并,也是推荐的规范操作

    git push origin feat-0131-jie

    先接着上面的提交步骤,将自己的分支推送到远程仓库。


    然后在线上代码仓库中,申请将自己的分支合并到 xx 分支(具体是哪个分支就根据你当前的开发进度来,如 test),然后在线上解决冲突。如果有权限就自己通过了,如果没有就得找 mt 啥的


    第二种,本地合并(前提你要有对应环境分支 push 的权限)

    ## 先切换到你要提交的环境分支上,如果本地还没有就先拉取下来
    git fetch origin test:test

    git checkout test

    #
    # 然后将自己的分支合并到环境分支上(在编辑器解决冲突)
    git merge feat-0131-jie

    #
    # 最后将环境分支推送到远程仓库
    git push origin test

    ## 先切换到你要提交的环境分支上,如果本地已有该分支,则需要先拉取最新代码
    git checkout test

    git pull origin test

    #
    # 然后将自己的分支合并到环境分支上(在编辑器解决冲突)
    git merge feat-0131-jie

    #
    # 最后将环境分支推送到远程仓库
    git push origin test

    两种方式有何区别?为什么推荐第一种?

    这是因为在团队协作开发的过程中,将合并操作限制在线上环境有以下几个好处:



    1. 避免本地合并冲突:如果多个开发人员同时在本地进行合并操作,并且对同一段代码进行了修改,可能会导致冲突。将合并操作集中在线上环境可以减少此类冲突的发生,因为不同开发人员的修改会先在线上进行合并,然后再通过更新拉取到本地。

    2. 更好的代码审查:将合并操作放在线上环境可以方便其他开发人员进行代码审查。其他人员可以在线上查看合并请求的代码变动、注释和讨论,并提供反馈和建议。这样可以确保代码的质量和可维护性。

    3. 提高可追溯性和可回滚性:将合并操作记录在线上可以更容易地进行版本控制和管理。如果出现问题或需要回滚到之前的版本,可以更轻松地找到相关的合并记录并进行处理。


    当然,并非所有情况都适用于第一种方式。在某些特定情况下,例如个人项目或小团队内部开发,允许本地合并也是可以的。但在大多数团队协作的场景中,将合并操作集中在线上环境具有更多优势。


    4. 验收完成,删除分支


    当我们这一版的需求完成后,本地肯定已经留有很多分支了,这些分支对于之后的开发已经意义不大了,留下来只会看着一团糟。


    git branch -d <分支名>

    #
    # 如果要强制删除分支(即使分支上有未合并的修改)
    git branch -D <分支名>

    四、一些小问题


    1. 前面提到,预发环境修完的 bug 要重新走测试再走预发,为什么呢?


    预生产环境是介于测试和生产环境之间的一个环境,它的目的是模拟生产环境并进行更真实的测试。
    它是一个重要的测试环境,需要保持稳定和可靠。通过对修复的bug再次提交到测试环境测试,可以确保预生产环境中的软件版本是经过验证的,并且没有明显的问题。


    当然,也不是非要这么做不可,紧急情况下,也可以选择直接发到预生产重新测试,只要你保证你的代码 99% 没问题了。


    2. 代码合并错误,并且已经推送到远程分支,如何解决?


    假设是在本地合并,本来要把特性分支合并到 uat 分支,结果不小心合到了 release 分支(绝对不是我自己的案例,绝对不是。。。虽然好在最后同事本地有我提交前的版本,事情就简单很多了)


    首先切换到特性分支合并到的错误分支,比如是 release


    git checkout release

    然后查看最近的合并信息


    git log --merges

    撤销合并


    git revert -m 1 <merge commit ID>


    • 这里的 merge commit ID 就是上一步查询出来的 ID 或者 ID 的前几个字符


    最后,撤销远程仓库的推送


    git push -f origin release


    • 这个命令会强制推送本地撤销合并后的 release 分支到远程仓库,覆盖掉远程仓库上的内容。(即,得通过一个新的提交来“撤销”上一次的提交,本质上是覆盖)


    3. 当前分支有未提交的修改,但是暂时不想提交,想要切换到另一个分支该怎么做?


    例如:你正在开发 B 需求,突然产品说 A 需求有点问题,让你赶紧改改,但是当前 B 需求还没开发完成,你又不想留下过多无用的提交记录,此时就可以按照下面这样做:


    首先,可以将当前修改暂存起来,以便之后恢复


    git stash

    然后切换到目标分支,例如需求 A 所在分支


    git checkout feat-a-jie

    修改完 A 需求后,需要先切换回之前的分支,例如需求 B 所在分支


    git checkout feat-b-jie

    如果你不确定之前所在的分支名,可以使用以下命令列出暂存的修改以及它们所属的分支:


    git stash list

    最后从暂存中恢复之前的修改


    git stash pop

    此时你的工作区就恢复如初了!




    喜欢本文的话,可以点赞收藏呀~😘


    如果有疑问,欢迎评论区留言探讨~🤔


    作者:JIE
    来源:juejin.cn/post/7327863960008392738
    收起阅读 »