How to optimize inner join query on one large table and one small table
Looks to me like the best plan is being used:


First scan the small table B to make the hash buckets,
then scan the big table A to find the matching rows from B via the hash.


You may have an I/O problem, 15 seconds for 15 million records, although if I assume that each row of table A is about 100 bytes long, then scanning it in 50 seconds is a throughput of about 30 MB/s, which isn't out of the ordinary on normal hardware, and depending on what else is going on.

And then, yes, you spend a lot of time in the DISTINCT sort. If you join and you get multiple matches, but you are not interested in the replicates at all, then it means you don't really need the columns of the joined table, except if your schema isn't normalized.

It seems that that's the clue actually.

SELECT distinct a.student_id, b.student_name, a.class_year
  FROM table_a a
  INNER JOIN table_b b on a.student_id = b.student_id;


The query doesn't seem to be logically thought through. You would get all those different class years, but nothing else from table_A. Look at your final result numbers, it says "rows=5078", which is about the size of table_B. You probably should normalize all these repeating class_years out of this massive table_A.