一、项目信息
项目介绍
参与开发社区充电桩管理系统,主要负责后端核心功能设计与实现。系统覆盖20+社区,接入超1000台充电桩,实现设备状态实时监控、扫码充电全流程自动化及运营数据统计。通过物联网技术与消息队列优化,故障响应时间缩短至15分钟内,用户充电成功率提升至99%以上,物业端操作效率提升30%。
技术栈
- 后端:
- 框架:Spring Boot(业务逻辑开发)、MyBatis Plus(数据库操作)
- 中间件:RabbitMQ(消息队列,处理支付通知、设备报警)、Redis(缓存设备状态、位置信息)
- 物联网:MQTT协议(设备通信,基于Paho客户端库)
- 数据库:MySQL(存储充电记录、用户信息)
- 前端:
- 技术:Vue.js(用户端页面)、百度地图API(充电桩位置展示)
- 协作:通过Swagger提供接口文档,配合前端联调设备状态同步、订单查询等功能
主要工作
-
设备接入与状态管理
- 基于MQTT协议开发服务端订阅功能,解析充电桩上报的状态数据(如在线、充电中、故障),实时更新Redis缓存和MySQL数据库。例如,设备通过Topic
charge/pile/001/status上报状态,后端解析后存入Redis键pile:001:status,并更新数据库charging_pile表。 - 设计心跳检测机制:充电桩每30秒发送心跳包,后端通过Redis记录最后心跳时间,定时任务扫描超时设备(超过5分钟未更新),触发故障报警流程,通过RabbitMQ发送报警短信至运维人员。
- 基于MQTT协议开发服务端订阅功能,解析充电桩上报的状态数据(如在线、充电中、故障),实时更新Redis缓存和MySQL数据库。例如,设备通过Topic
-
充电业务流程开发
- 实现扫码充电核心逻辑:用户扫码后,后端通过Redis分布式锁防止并发占用设备,调用支付宝接口生成支付二维码。支付结果通过RabbitMQ异步处理,更新订单状态并发送通知。例如,使用
set lock:pile:001 user123 nx px 5000获取锁,确保同一设备同一时间仅被一个用户操作。 - 开发预约功能:用户可预约空闲充电桩,预约信息存入Redis(键
reserve:pile:001,有效期30分钟),定时任务自动释放过期预约,避免资源浪费。
- 实现扫码充电核心逻辑:用户扫码后,后端通过Redis分布式锁防止并发占用设备,调用支付宝接口生成支付二维码。支付结果通过RabbitMQ异步处理,更新订单状态并发送通知。例如,使用
-
数据存储与性能优化
- 设计
charging_pile和charging_record表,通过MyBatis Plus实现CRUD,在status和start_time字段添加索引提升查询效率。例如,查询在线设备时通过status索引快速过滤数据。 - 利用Redis缓存高频数据:充电桩位置信息(经纬度)使用GeoHash存储,支持附近设备查询;实时费用计算结果存入Redis哈希结构,减少数据库压力。
- 设计
-
消息队列与异常处理
- RabbitMQ应用于支付回调和故障报警场景。支付成功后,消息队列异步更新订单状态,失败消息进入死信队列重试;设备故障时,通过队列触发短信通知,确保通知可靠性。
- 全局异常处理:通过Spring的
@ControllerAdvice捕获业务异常(如设备忙、支付失败)和系统异常,返回统一错误格式,结合日志系统记录详细信息,便于故障追溯。
-
接口开发与协作
- 提供扫码充电、设备查询、订单统计等核心接口,通过Swagger文档明确参数格式与返回结果。例如,
/charge/start接口接收设备SN,返回支付二维码URL;/pile/nearby接口根据经纬度返回附近设备列表。 - 配合前端联调设备状态展示、订单实时更新等功能,确保前后端数据一致性,例如通过WebSocket推送充电进度通知。
- 提供扫码充电、设备查询、订单统计等核心接口,通过Swagger文档明确参数格式与返回结果。例如,
二、面试题及回答
1.面试官问: 你在项目里用了MQTT协议,能说说具体怎么用的吗?
应试者回答:
主要用来接收充电桩的状态数据。后端用Paho客户端库连接MQTT服务器,订阅类似charge/pile/+的Topic,这样所有充电桩的状态消息都会被捕获。收到消息后,解析里面的设备SN、状态、充电量等数据,先存到Redis里,方便前端快速查询,同时写入MySQL做持久化记录。
面试官追问:
那如果设备上报的数据量很大,后端处理不过来怎么办?
应试者回答:
一开始确实遇到过消息堆积的问题,后来加了多线程消费者。在RabbitMQ里配置多个消费者线程,每个线程独立处理消息,比如同时开3个线程处理不同的Topic,这样吞吐量就上去了。另外,消息处理的时候先做快速校验,比如只存关键数据,复杂的业务逻辑放到异步任务里处理。
面试官再追问:
MQTT的消息会不会丢?怎么保证设备状态不丢失?
应试者回答:
用了QoS 1级别,消息至少会送达一次。而且Redis和数据库都做了持久化,就算后端重启,数据也能恢复。比如,设备状态先存Redis,每隔1分钟批量写入数据库,就算中间服务挂了,重启后也能从Redis里恢复未写入的数据。
2.面试官问: 项目里Redis主要缓存什么?举个例子。
应试者回答:
存得最多的是设备状态和位置信息。比如,设备状态存在pile:001:status里,包括在线状态、最后心跳时间;位置信息用GeoHash存,比如GEOADD pile_locs 001 116.4 39.9,这样查附近设备时直接用Redis的GEORADIUS命令,比查数据库快很多。另外,预约信息也存在Redis里,比如reserve:pile:001存预约用户ID和时间。
面试官追问:
如果数据库里的设备费率改了,Redis没更新怎么办?
应试者回答:
我们在管理后台改费率的时候,会先更新数据库,然后立即删除对应的Redis缓存。比如,调用redisTemplate.delete("pile:001:fee_rate"),这样下次查询的时候,就会重新从数据库加载最新的费率。如果是批量修改,就发一个消息到RabbitMQ,让后台任务批量清理缓存,保证一致性。
面试官再追问:
有没有遇到过缓存穿透?怎么解决的?
应试者回答:
遇到过前端传了不存在的设备SN来查询状态,每次都打数据库。后来加了布隆过滤器,把所有存在的设备SN提前存进去,查询前先过一遍布隆过滤器,不存在的直接返回,这样数据库的压力就小多了。布隆过滤器用Guava库实现的,初始化的时候加载所有设备SN,虽然占点内存,但效果挺好。
3.面试官问: 用户扫码的时候,怎么避免两个人同时启动同一个充电桩?
应试者回答:
用Redis的分布式锁,关键是setIfAbsent这个命令。比如,用户扫码后,后端调用redisTemplate.opsForValue().setIfAbsent("lock:pile:001", "user123", 5, TimeUnit.SECONDS),这里nx参数表示只有锁不存在时才创建,相当于抢占锁。拿到锁之后,再查一下设备状态,确认是空闲的,才允许启动充电。
面试官追问:
如果锁过期了,但是充电流程还没走完,怎么办?
应试者回答:
这时候可能会有其他用户抢到锁,所以在操作的时候,每次执行关键步骤前都再校验一次设备状态。比如,生成支付二维码之前,再查一次数据库,看看设备是不是还处于空闲状态。如果已经被占用了,就返回错误。虽然有点重复查询,但能保证准确性。
面试官再追问:
为什么不直接用Redisson?自己写锁有什么缺点?
应试者回答:
项目初期需求比较简单,自己写几行代码就够用了,不想引入太多依赖。不过自己写的锁确实在续期的时候有点麻烦,比如预约功能需要更长时间的锁,就得自己处理过期问题。后来考虑过用Redisson的看门狗机制,自动延长锁的时间,但项目里还没来得及集成,准备后续优化。
4.面试官问: 项目里用RabbitMQ处理什么逻辑?能详细说说吗?
应试者回答:
主要用在支付结果处理和故障报警。比如,用户支付成功后,支付宝会回调我们的接口,这时候后端不直接处理,而是发一条消息到RabbitMQ的payment_queue里。消费者收到消息后,再去更新订单状态、发通知短信,这样用户不用等待这些操作完成,体验更好。故障报警也是类似,设备离线后,发消息到alarm_queue,触发短信通知运维。
面试官追问:
如果RabbitMQ挂了,消息会丢吗?怎么保证不丢?
应试者回答:
不会丢,因为我们开了持久化。队列声明的时候设置durable=true,消息发送时设置setPersistent(true),这样消息会存到磁盘里。就算RabbitMQ重启,消息也还在。另外,生产者确认机制也开了,发送消息后会等Broker返回确认,没收到的话就重试,最多试3次,确保消息发出去。
面试官再追问:
死信队列里的消息怎么处理?会影响正常业务吗?
应试者回答:
死信队列里的是处理失败的消息,比如支付结果解析出错。我们专门写了一个消费者来处理死信队列,每小时扫描一次,对于重复失败超过5次的消息,记录到数据库里,人工介入处理。死信队列是独立的,不会影响正常队列的消费,而且我们每周会清理一次旧消息,避免积压太多。
5.面试官问: 设备离线报警是怎么实现的?
应试者回答:
用Spring的定时任务,每分钟扫描一次Redis里的设备心跳时间。比如,每个设备在Redis里都有一个last_heartbeat_time,如果当前时间减去这个时间超过5分钟,就认为设备离线了。这时候发一条消息到RabbitMQ的报警队列,消费者收到后,调用短信接口通知运维人员,同时在管理后台把设备状态标红。
面试官追问:
除了离线,还有其他类型的故障吗?怎么处理?
应试者回答:
设备上报的消息里有错误码,比如error_code=101表示充电模块故障,error_code=102表示电压异常。后端解析到这些错误码后,会根据级别不同处理,比如模块故障发紧急短信,电压异常发普通警告。同时,在数据库里记录故障日志,方便运维人员排查。
面试官再追问:
报警短信发送失败怎么办?比如短信接口超时。
应试者回答:
短信接口有重试机制,第一次失败后,隔1分钟再试,最多试3次。如果还是失败,就把报警信息存到数据库的alarm_log表,状态标记为“未发送”,每天生成一个报表,让运维人员手动补发。虽然有点麻烦,但这种情况很少发生,大部分时候都能成功。
6.面试官问: 查询在线设备时,怎么让接口响应更快?
应试者回答:
首先在charging_pile表的status字段加了索引,这样查在线设备时SELECT sn, status FROM charging_pile WHERE status='ONLINE'就快很多。然后把常用的字段,比如经纬度、费率,缓存到Redis里,减少数据库的查询次数。对于特别频繁的查询,比如首页的设备统计,直接用Redis缓存结果,30分钟更新一次。
面试官追问:
充电记录按月统计时很慢,怎么处理的?
应试者回答:
按start_time字段分表了,每个月一个表,比如charging_record_202301、charging_record_202302。查询的时候,根据月份自动路由到对应的表,这样数据量就小了。另外,用MyBatis Plus的@Cacheable注解,把统计结果缓存起来,用户再次查询时直接从缓存取,不用重新计算。
面试官再追问:
分表后,跨月查询怎么办?
应试者回答:
尽量避免跨月查询,前端限制只能查最近6个月的数据。如果必须跨月,就用Elasticsearch同步数据,ES的聚合功能查起来更快。不过项目里暂时没做,因为大部分查询都是按月统计,跨月需求很少。
7.面试官问: Spring Boot里怎么配置MQTT?能说说关键步骤吗?
应试者回答:
首先在配置文件里写MQTT服务器的地址和客户端ID,比如:
spring.mqtt.client.url=tcp://mqtt.example.com:1883
spring.mqtt.client.client-id=charge-backend
然后写一个配置类,注入MqttClient Bean,用@PostConstruct初始化连接,并订阅需要的Topic,比如charge/pile/+。收到消息后,通过自定义的监听器解析消息内容,调用业务层处理数据。
面试官追问:
网络中断后,MQTT客户端会自动重连吗?
应试者回答:
会的,在MqttConnectOptions里设置setAutomaticReconnect(true),这样网络恢复后会自动重连。重连的时候,会从上次断开的位置继续接收消息,保证不丢数据。不过重连期间的消息可能会堆积,所以我们在客户端加了流量控制,避免重连后突然大量消息涌入。
面试官再追问:
有没有遇到过消息重复消费?怎么解决的?
应试者回答:
QoS 1模式下可能会有重复消息,我们在数据库里加了message_id字段,每次处理消息时校验是否已经处理过。消息ID由设备SN和时间戳组成,保证唯一。如果收到重复消息,直接跳过,这样就不会重复记录了。
8.面试官问: 预约充电桩时,分布式锁怎么保证唯一性?
应试者回答:
预约时,后端会在Redis里创建一个reserve:pile:001的键,用setIfAbsent设置有效期30分钟。比如,set reserve:pile:001 user123 nx px 1800,nx参数保证只有第一个预约的用户能创建成功,后面的用户会收到“预约失败,设备已被预约”的提示。
面试官追问:
预约过期后,锁怎么自动释放?
应试者回答:
Redis会自动删除过期的键,不用手动处理。不过为了保险,我们还是加了一个定时任务,每天凌晨扫描所有预约记录,删除超过24小时的过期键,防止Redis里堆积太多无效数据,影响性能。
面试官再追问:
如果用户预约后,管理员手动占用设备,怎么处理锁冲突?
应试者回答:
管理员操作时,后端会先检查是否存在预约锁,如果有,就先删除Redis里的预约键,再更新设备状态为“占用”。这样用户端刷新后,会看到设备状态变更,预约自动失效,避免冲突。
9.面试官问: 查附近充电桩时,Redis的GeoHash怎么提升效率?
应试者回答:
把充电桩的经纬度用GeoHash编码后存到Redis,查询时用GEORADIUS命令按距离排序。比如,用户的位置是116.4, 39.9,查1公里内的设备,命令是GEORADIUS pile_locs 116.4 39.9 1 km WITHCOORD COUNT 20,这样会返回20个最近的设备,速度比数据库快很多,因为Redis是内存操作。
面试官追问:
GeoHash的精度怎么选择?
应试者回答:
我们用6位GeoHash,精度大概1公里,适合社区范围内的搜索。如果用7位,精度能到100米,但会更占内存。根据项目需求,社区里用户一般找附近几百米的设备,1公里的精度已经够用了,所以选了6位,平衡性能和存储。
面试官再追问:
如果Redis是集群模式,GeoHash查询会有问题吗?
应试者回答:
单机模式下没问题,集群模式下可能涉及跨节点查询,因为GeoHash的范围可能覆盖多个分片。我们项目目前用的是单机Redis,设备量还没到需要集群的程度。如果未来设备增多,可能会考虑用Redis Cluster,或者按区域分片存储,确保同一区域的设备在同一个节点上。
10.面试官问: 支付回调接口怎么防止重复处理?
应试者回答:
在数据库的charging_record表加了payment_no字段,记录支付平台的唯一订单号。每次收到回调消息,先查一下这个payment_no是否已经存在,如果存在,就直接返回成功,不再重复处理。这样就算支付平台重复通知,也不会生成重复订单。
面试官追问:
如果支付平台的payment_no不唯一怎么办?
应试者回答:
这种情况概率很低,不过我们也做了兜底处理。在数据库里给payment_no加了唯一索引,如果重复插入会抛异常,后端捕获这个异常,记录日志并通知运维人员手动处理。实际中还没遇到过这种情况,因为支付平台的订单号都是唯一的。
面试官再追问:
除了数据库,还有其他方法吗?
应试者回答:
可以用Redis缓存已处理的payment_no,设置过期时间24小时。收到回调消息先查Redis,存在就直接返回成功。这种方法适合高并发场景,减少数据库压力。我们项目里因为支付量不是特别大,所以先用了数据库唯一键的方式,简单直接。
11.面试官问: 管理后台导入充电桩Excel时,怎么避免内存溢出?
应试者回答:
用EasyExcel的流式读取,一行一行读数据,不一次性加载到内存里。比如,写一个监听器,每读100行就批量插入数据库,读完后清理列表。这样不管Excel有多大,内存占用都不会太高。代码大概就是重写invoke方法,积累到100条就调用saveBatch,然后清空列表。
面试官追问:
导入过程中用户取消了怎么办?
应试者回答:
在数据库里建了一个import_task表,记录任务的状态。用户点击取消时,更新任务状态为“已取消”,后端线程检查到状态变更,就会终止导入,同时回滚已经插入的数据,避免脏数据。如果已经导入了一部分,会把这部分标记为无效,等用户重新导入时覆盖。
面试官再追问:
Excel里有格式错误怎么处理?比如经纬度填成了文字。
应试者回答:
在监听器里做数据校验,比如经纬度必须是数字,格式符合要求。遇到错误的行,记录到一个错误列表里,跳过这一行继续处理下一行。导入完成后,给用户返回一个错误报告,里面有错误的行数和原因,方便用户修改后重新导入。
12.面试官问: 用户反馈扫码后设备没反应,你会怎么排查?
应试者回答:
首先看前端返回的错误码,比如接口是否返回409(冲突),可能是设备被占用了。然后查Redis里的锁是否存在,如果有锁且不是当前用户的,可能是之前的操作没释放锁,手动删除锁试试。接着检查设备状态是否在线,可能设备离线了,这时候要看MQTT日志有没有收到心跳包,判断是设备问题还是网络问题。最后查数据库的充电记录,看是否有未完成的订单,可能需要强制结束异常订单。
面试官追问:
如果接口返回500错误,怎么定位原因?
应试者回答:
去查elk日志,根据请求ID搜索对应的记录,看看异常堆栈是什么。如果是Redis连接超时,就检查Redis服务器是否正常,内存是否充足;如果是数据库操作失败,比如唯一键冲突,就查插入的数据是否重复;如果是MQTT消息发送失败,就检查MQTT服务器的连接状态。日志里会有详细的错误信息,跟着堆栈走就能找到问题点。
面试官再追问:
怎么避免类似问题频繁发生?
应试者回答:
根据故障原因优化代码,比如给Redis操作加超时重试,避免长时间阻塞;给数据库唯一键冲突添加友好的提示信息,让用户知道哪里出错了;给设备锁添加自动释放机制,定时扫描过期的锁并删除。同时,完善监控告警,比如设置Redis内存使用率超过80%时报警,提前扩容,避免服务崩溃。
13.面试官问: 项目里RabbitMQ的消费者开了多少线程?怎么确定这个数值的?
应试者回答:
默认开了3个线程,因为项目里同时处理的消息量不是特别大。这个数值是根据压测结果定的,当时用JMeter模拟了1000条消息,3个线程处理时吞吐量最高,再多的话CPU利用率上不去,反而效率下降。如果未来消息量增加,可以动态调整线程数,比如通过配置文件修改concurrency参数。
面试官追问:
消费者线程数太多有什么问题吗?
应试者回答:
线程数太多会占用更多系统资源,比如CPU和内存,可能导致服务器负载过高。而且线程之间的上下文切换也会消耗性能,反而降低处理效率。所以需要根据实际的消息量和服务器配置来调整,不是越多越好。
面试官再追问:
怎么监控消费者的处理速度?
应试者回答:
用RabbitMQ的管理后台,查看队列的消息堆积情况和消费者的吞吐量。如果队列里的消息堆积量持续增加,说明消费者处理速度跟不上,这时候就需要增加线程数或者优化处理逻辑。另外,通过Spring Boot Actuator暴露消费者的指标,比如每分钟处理的消息数,放到Grafana里实时监控。
14.面试官问: 在充电流程中,数据库事务是怎么保证一致性的?
应试者回答:
在创建充电订单和更新设备状态时,用了Spring的@Transactional注解,保证这两个操作要么都成功,要么都失败。比如,扫码后先创建订单记录,再更新设备状态为“充电中”,如果更新状态失败,订单记录也会回滚,避免出现订单存在但设备状态未变更的情况。
面试官追问:
事务的隔离级别用的什么?为什么选这个?
应试者回答:
用了默认的READ_COMMITTED,因为项目里读多写少,这个隔离级别可以避免脏读,又不会像可重复读那样锁定数据,影响性能。比如,查询设备状态时,不会读到未提交的事务数据,保证查询结果的一致性。
面试官再追问:
如果事务里有耗时操作,怎么优化?
应试者回答:
尽量把耗时操作放到事务外面,比如发送通知短信、记录日志等。比如,创建订单和更新设备状态在事务内完成,发送短信放到事务外,通过RabbitMQ异步处理,这样事务的执行时间就会缩短,减少锁的持有时间,提升并发性能。
15.面试官问: 调用支付宝支付接口时,如果超时怎么办?
应试者回答:
设置了超时时间,比如5秒,超过这个时间就抛出异常,给用户提示“支付请求超时,请重试”。同时,在数据库里记录订单状态为“支付中”,开启一个定时任务,每隔1分钟查询一次支付结果,直到支付成功或失败,避免订单一直处于中间状态。
面试官追问:
定时任务怎么实现的?用的Spring Schedule吗?
应试者回答:
是的,用@Scheduled(fixedRate = 60000)开了一个定时任务,每分钟扫描所有状态为“支付中”的订单,调用支付宝的查询接口获取最新状态。如果超过24小时还没结果,就自动取消订单,释放设备锁,避免资源长时间占用。
面试官再追问:
如果多个定时任务同时扫描同一订单,怎么避免并发问题?
应试者回答:
在数据库的订单表加了一个lock_version字段,每次更新订单状态时,版本号加1。定时任务查询订单时,带上当前版本号,更新时校验版本号是否一致,不一致就跳过,避免并发更新导致的数据错乱。这其实是乐观锁的思路,简单有效。