JavaWeb(1)⾼并发业务
  互联⽹⽆时⽆刻不⾯对着⾼并发问题,例如商品秒杀、抢红包、⼤麦⽹抢演唱会门票等。
  当⼀个Web系统,在⼀秒内收到数以万计甚⾄更多的请求时,系统的优化和稳定是⾄关重要的。
  互联⽹的开发包括Java后台、NoSQL、数据库、限流、CDN、负载均衡等。
  ⼀、互联系统应⽤架构基础分析
  防⽕墙的功能是防⽌互联⽹上的病毒和其他攻击,正常的请求通过防⽕墙后,最先到达的就是负载均衡器。
  负载均衡器的主要功能:java dubbo
对业务请求做初步的分析,决定分不分发请求到Web服务器,常见的分发软件⽐如Nginx和Apache等反向代理服务器,它们在关卡处可以通过配置禁⽌⼀些⽆效的请求,⽐如封禁经常作弊的IP地址,也可以使⽤Lua、C语⾔联合 NoSQL 缓存技术进⾏业务分析,这样就可以初步分析业务,决定是否需要分发到服务器
提供路由算法,它可以提供⼀些负载均衡算法,根据各个服务器的负载能⼒进⾏合理分发,每⼀个Web服务器得到⽐较均衡的请求,从⽽降低单个服务器的压⼒,提⾼系统的响应能⼒。
限流,对于⼀些⾼并发时刻,如双⼗⼀,需要通过限流来处理,因为可能某个时刻通过上述的算法让有效请求过多到达服务器,使得⼀些Web服务器或者数据库服务器产⽣宕机。当某台机器宕机后,会使得其他服务器承受更⼤的请求量,这样就容易产⽣多台服务器连续宕机的可能性,持续下去就会引发服务器雪崩。因此,在这种情况下,负载均衡器有限流的算法,对于请求过多的时刻,可以告知⽤户系统繁忙,稍后再试,从⽽保证系统持续可⽤。
  为了应对复杂的业务,可以把业务存储在 NoSQL 上,通过C语⾔或者Lua语⾔进⾏逻辑判断,它们的性能⽐Web服务器判断的性能要快速得多,从⽽降低Web服务器的压⼒,提⾼互联⽹系统的响应速度。
  ⼆、应对⽆效请求
  在负载均衡器转发给Web服务器之前,使⽤C语⾔和Redis进⾏判断是否是⽆效请求。对于黄⽜组织,可以考虑僵⼫账号排除法进⾏应对。
  三、系统设计
  ⾼并发系统往往需要分布式的系统分摊请求的压⼒,要尽量根据 Web 服务器的性能进⾏均衡分配请求。 
  划分系统可以按照业务划分,即⽔平划分。也可以不按照业务分,即垂直划分。
  按照业务划分可以提⾼开发效率以及更⽅便地设计数据库。但是,还要通过RPC(Remote Procedure Call Protocol)远程过程调⽤协议处理这些信息,例如Dubbo、Thrift和Hessian等。
  四、数据库设计
  为了得到⾼性能,可以使⽤分表或分库技术,从⽽提⾼系统的响应能⼒。
  分表是指在⼀个数据库内本来⼀张表可以保存的数据,设计成多张表去保存。例如,将每⼀年的交易记录分别分成交易表,⽽不是只有⼀张交易表来记录所有的交易记录。
  分库是把表数据分配在不同的数据库中,分库⾸先需要⼀个路由算法确定数据在哪个数据库上,然后才能进⾏查询,这⾥可以把⽤户和对应业务的数据库的信息缓存到Redis中,这样路由算法就可以通过Redis从读取的数据来决定使⽤哪个数据库进⾏查询了。
  另外,还可以考虑SQL优化,建⽴索引等优化,提⾼数据库的性能。
  五、动静分离技术
  对于互联⽹⽽⾔,⼤部分数据都是静态数据,只有少数使⽤动态数据,动态数据的数据包很⼩,不会造成⽹络瓶颈,⽽静态的数据则不⼀样,静态数据包含图⽚、CSS、JavaScript 和视频等互联⽹的应⽤,
尤其是图⽚和视频占据的流量很⼤,如果都从动态服务器(⽐如Tomcat、WildFly和WebLogic等)获取,那么动态服务器的带宽压⼒会很⼤,这个时候应该考虑动静分离技术。
  可以使⽤静态HTTP服务器,例如Apache,将静态数据分离到静态HTTP服务器上,这样图⽚、HTML、脚本等资源都可以从静态服务器上获取,尽量使⽤ Cookie 等技术,让客户端缓存能够缓存数据,避免多次请求,降低服务器的压⼒。
  企业可以还可以使⽤⾼级的动静分离技术,例如CDN(Content Delivery Network,即内容分发⽹络),它允许企业将⾃⼰的静态数据缓存到⽹络 CDN 的节点中,对于⽤户的请求,直接通过CDN算法去指定的CDN节点去响应请求。
  六、锁和⾼并发
  ⽆论区分有效请求和⽆效请求、⽔平划分还是垂直划分、动静分离技术,还是数据库分表、分库技术,都⽆法避免动态数据,⽽动态数据的请求最终也会落在⼀台 Web 服务器上。
  例如,发放⼀个总额为 20 万元的红包,拆分成 2 万个⾦额为 10 元的⼩红包,供给⽹站的 3 万个会员在线抢夺,这就是⼀个典型的⾼并发的场景。
  由于会出现多个线程同时发起请求,由于线程每⼀步完成的顺序不⼀样,这样会导致数据的⼀致性问
题。
  为了保证数据⼀致性,可以使⽤加锁的⽅式,但是加锁会影响并发,从⽽影响系统的性能,⽽不加锁就难以保证数据的⼀致性,这就是锁和⾼并发的⽭盾。
  为了解决锁和⾼并发的⽭盾,⼤部分企业提出了悲观锁和乐观锁的概念,
对于数据库⽽⾔,如果在短时间内需要执⾏⼤量SQL,对于服务器的压⼒可想⽽知,需要优化数据库的表设计、索引、SQL语句等。
还可以使⽤ Redis 事务和 Lua 语⾔所提供的原⼦性来取代现有的数据库技术,从⽽提⾼数据的存储响应,以应对⾼并发场景,但是严格来说也属于乐观锁。
  0.T_RED_PACKET为红包表,T_USER_RED_PACKET为⽤户抢红包表
  (0)未加锁情况下,并发导致了数据的不⼀致
<!-- 查询红包具体信息 -->
<select id="getRedPacket" parameterType="long"
resultType="com.ssm.chapter22.pojo.RedPacket">
select id, user_id as userId, amount, send_date as
sendDate, total,
unit_amount as unitAmount, stock, version, note from
T_RED_PACKET
where id = #{id}
</select>
  业务逻辑:
@Override
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacket(Long redPacketId, Long userId) {
/
/ 获取红包信息
// RedPacket redPacket = RedPacket(redPacketId);
// 悲观锁
RedPacket redPacket = RedPacketForUpdate(redPacketId);
// 当前⼩红包库存⼤于0
if (Stock() > 0) {
redPacketDao.decreaseRedPacket(redPacketId);
// ⽣成抢红包信息
UserRedPacket userRedPacket = new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.UnitAmount());
userRedPacket.setNote("抢红包 " + redPacketId);
// 插⼊抢红包信息
int result = apRedPacket(userRedPacket);
return result;
}
// 失败返回
return FAILED;
}
  1.使⽤数据库的悲观锁和乐观锁进⾏设计
  (1)悲观锁
  悲观锁是⼀种利⽤数据库内部机制提供的锁的⽅法,也就是对更新的数据加锁,这样在并发期间⼀旦有⼀个事务持有了数据库记录的锁,其他的线程将不能再对数据进⾏更新了。
  修改SQL语句,加⼊“for update”,意味着将持有对数据库记录的⾏更新锁(因为这⾥使⽤主键查询,所以只会对⾏加锁。如果使⽤的是⾮主键查询,要考虑是否对全表加锁的问题,加锁后可能引发其他查询的阻塞),那就意味着在⾼并发的场景下,当⼀条事务持有了这个更新锁后才能往下操作,其他的线程如果要更新这条记录都需要等待,这样就不会出现超发现象引发的数据不⼀致的问题了。
  但是,悲观锁会导致系统性能下降。对于悲观锁来说,当⼀条线程抢占了资源后,其他的线程将得不到资源,那么这个时候,CPU 就会将这些得不到资源的线程挂起,挂起的线程也会消耗 CPU 的资源。由于⾼并发环境下的频繁挂起线程和恢复线程,导致CPU频繁切换线程上下⽂,从⽽使CPU资源得到了极⼤的消耗,造成了性能不佳的问题。悲观锁也称独占锁。
  业务逻辑和不加锁时⼀致。
<!-- 查询红包具体信息 -->
<select id="getRedPacketForUpdate" parameterType="long"
resultType="com.ssm.chapter22.pojo.RedPacket">
select id, user_id as userId, amount, send_date as
sendDate, total,
unit_amount as unitAmount, stock, version, note
from
T_RED_PACKET where id = #{id} for update
</select>
  (2)乐观锁
  乐观锁是⼀种不会阻塞其他线程并发的机制,它不会使⽤数据库的锁进⾏实现,它的设计⾥⾯由于不阻塞其他线程,所以不会引发线程频繁挂起和恢复,这样可以提⾼并发能⼒。乐观锁也称为⾮阻塞锁。乐观锁使⽤的是CAS原理。
  CAS原理并不排斥并发,也不独占资源,只是在线程开始阶段就读⼊线程共享数据,保存为旧值。当处理完逻辑,需要更新数据的时候,会进⾏⼀次⽐较,即⽐较各个线程当前共享的数据是否和旧值保持
⼀致,如果⼀致,就开始更新数据,如果不⼀致,就认为该数据已经被其他线程修改了,那么就不再更新数据,可以考虑重试或者放弃。有时候可以重试,这样就是⼀个可重⼊锁。
  CAS原理存在ABA问题,ABA问题是因为业务逻辑存在回退的可能性。如果加⼊⼀个⾮业务逻辑的属性,⽐如在⼀个数据中加⼊版本号,每次修改变量的数据时,强制版本号只能递增,⽽不会回退,即使是其他业务数据回退,它也会递增,那么就解决了ABA问题。
  对于查询来说,没有“for update”语句,避免了锁的发⽣,就不会造成线程阻塞。然后,增加了对版本号的判断,其次每次扣减都会对版本号加1,这样就可以避免ABA问题了。
<!-- 通过版本号扣减抢红包每更新⼀次,版本增1,其次增加对版本号的判断 -->
<update id="decreaseRedPacketForVersion">
update T_RED_PACKET
set stock = stock - 1,
version = version + 1
where id = #{id}
and version = #{version}
</update>
  在Service中使⽤乐观锁,⽆重⼊的代码:其中,redPacket先获取到了红包的记录,其Version()表⽰的就是version版本值,在执⾏更新数据库⽅法时,将Version()传⼊作为version变量,由于在判断时增加了“and version = #{version}”语句,因此,如果不相等,就不执⾏update SQL语句,因此update返回值就为0,表明版本号发⽣了变化。
// 乐观锁,⽆重⼊
@Override
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
// 获取红包信息,注意version值
RedPacket redPacket = RedPacket(redPacketId);
/
/ 当前⼩红包库存⼤于0
if (Stock() > 0) {
// 再次传⼊线程保存的version旧值给SQL判断,是否有其他线程修改过数据
int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, Version());
// 如果没有数据更新,则说明其他线程已经修改过数据,本次抢红包失败
if (update == 0) {
return FAILED;
}
// ⽣成抢红包信息
UserRedPacket userRedPacket = new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.UnitAmount());
userRedPacket.setNote("抢红包 " + redPacketId);
// 插⼊抢红包信息
int result = apRedPacket(userRedPacket);
return result;
}
// 失败返回
return FAILED;
}
  在实际测试的情况下,经过3万次的抢夺,原来的2万个红包中,由于版本号version不⼀致导致了还有8千多个没有被抢到,也就是说,抢红包失败的记录太⼤了。
  (3)乐观锁重⼊机制
  为了克服这个问题,提⾼成功率,还会考虑使⽤锁重⼊机制。也就是⼀旦因为版本原因没有抢到红包,则重新尝试抢红包,但是过多的重⼊会造成⼤量的SQL执⾏,有两种⽅式:
按时间戳重⼊,也就是在⼀定的时间内(例如:当前时间+100毫秒),不成功的会循环到成功为⽌,直⾄超过时间戳,不成功才会退出,返回失败。
按次数重⼊,⽐如限定3次,如果超过3次尝试后还失败,那么就判定此次失败
  按时间戳重⼊:有时候时间戳并不是那么稳定,也会随着系统的空闲或者繁忙导致重试次数不⼀。
// 乐观锁,按时间戳重⼊
@Override
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
// 记录开始时间
long start = System.currentTimeMillis();
// ⽆线循环,直到成功或者满100毫秒退出
while (true) {
// 获取循环当前时间
long end = System.currentTimeMillis();
// 如果超过了100毫秒就结束尝试
if (end - start > 100) {
return FAILED;
}
... 和乐观锁部分⼀样
}
  按次数重⼊:限制重试次数,这样就能避免过多的重试导致过多的SQL被执⾏,从⽽保证数据库的性能。
// 乐观锁,按重试次数重⼊
@Override
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
for (int i = 0; i < 3; i++) {
// 获取红包信息,主要是version值
RedPacket redPacket = RedPacket(redPacketId);
// 当前⼩红包库存⼤于0
if (Stock() > 0) {
// 再次传⼊线程保存的version旧值给SQL判断,是否有其他线程修改过数据
int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, Version());
// 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺
if (update == 0) {
continue;
}
...
}
  2.使⽤Redis进⾏设计
  数据库最终会将数据保存到磁盘中,⽽Redis使⽤的是内存,内存的速度⽐磁盘速度快得多。
  Redis的Lua语⾔是原⼦性的,且功能更为强⼤,因此优先选择Lua语⾔实现抢红包业务。
  Redis并⾮是⼀个长久存储数据的地⽅,它存储的数据是⾮严格和安全的环境,更多的时候只是为了提
供更为快速的缓存,因此当红包⾦额为0或者红包超时的时候,将红包数据保存到数据库中,这样才能够保证数据的安全性和严格性。 
  (0)表设计
  使⽤下⾯的Redis命令在Redis中初始化了⼀个编号为5的⼤红包,其中库存为2万个,每个10元
127.0.0.1:6379> hset red_packet_5 stock 20000
(integer) 1
127.0.0.1:6379> hset red_packet_5 unit_amount 10
(integer) 1
  在数据库中通过下⾯的SQL语句创建⽤户抢红包信息表:
create table T_USER_RED_PACKET
(
id                  int(12)                        not null auto_increment,
red_packet_id        int(12)                        not null,
user_id              int(12)                        not null,
amount              decimal(16,2)                  not null,
grab_time            timestamp                      not null,
note                varchar(256)                  null,
primary key clustered (id)
);
  (1)Lua脚本设计
// Lua脚本
String script = "local listKey = 'red_packet_list_'..KEYS[1] \n"   // 被抢红包列表 key
        + "local redPacket = 'red_packet_'..KEYS[1] \n"    // 当前被抢红包 key
    + "local stock = tonumber(redis.call('hget', redPacket, 'stock')) \n" // 读取当前红包库存
    + "if stock <= 0 then return 0 end \n"  // 没有库存,返回0
    + "stock = stock -1 \n"           // 库存减1
    + "redis.call('hset', redPacket, 'stock', tostring(stock)) \n"  // 保存当前库存
    + "redis.call('rpush', listKey, ARGV[1]) \n"     // 往Redis链表中加⼊当前红包信息
    + "if stock == 0 then return 2 end \n"     // 如果是最后⼀个红包,则返回2,表⽰抢红包已经结束,需要将Redis列表中的数据保存到数据库中    + "return 1 \n";    // 如果并⾮最后⼀个红包,则返回1,表⽰抢红包成功。

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。